asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-25 00:17:31 +02:00
parent 46111d8988
commit ac2d95fc56
140 changed files with 1168 additions and 1984 deletions

70
custom/apps/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Module
Installierbare und fachliche Apps liegen in diesem Projekt unter `custom/apps/<app>/`.
Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben.
## Aktuelle Modulstruktur
Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen:
- `desktop.php` fuer die Desktop-App-Metadaten und Asset-Definitionen
- `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte
- `api/` fuer modulinterne HTTP-Endpunkte
- `assets/` fuer modulnahe CSS- und JS-Dateien
- `docs/README.md` fuer modulspezifische Hinweise, API und Sonderregeln
- `module.json` fuer Setup, Desktop-Freigabe, Widget-Funktionen und Cron-Endpunkte
Custom-App-Assets werden nicht direkt aus `public/` geladen, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen App-Ordner ausgeliefert.
## Trennung zwischen Systemtools und Modulen
- `Systemtools` sind globale Verwaltungs- und Setup-Apps und nicht installierbar
- aktuelle Beispiele: `User Management`, `User Self Management`, `Cron Tool`
- `Custom Apps` sind installierbare Fachanwendungen
- aktuelle Beispiele: `Mining-Checker`, `Waehrungs-Checker`, `Boersenchecker`, `Pi-hole`
Diese Trennung wird ueber App-Metadaten gesteuert:
- `app_scope`
- `core`
- `system_tool`
- `module`
- `installable`
- `false` fuer Core- und Systemtools
- `true` fuer installierbare Module
## Modul-Metadaten in `module.json`
Module sollen ihre Desktop-Faehigkeiten zentral in `module.json` beschreiben.
Wichtige Bausteine:
- `desktop`
- `available`
- `show_on_desktop`
- `show_in_start_menu`
- `widgets`
- beschreibt Widget-Funktionen, die nur fuer installierte Module verfuegbar sind
- `cron_jobs`
- beschreibt Cron-Endpunkte fuer die zentrale Cron-Verwaltung
Die Desktop-Shell liest diese Angaben automatisch ein. Neue Modul-Crons und Widgets muessen deshalb nicht zusaetzlich in einer separaten globalen Liste nachgetragen werden, solange sie sauber im Manifest beschrieben sind.
## Globale Desktop-Standards fuer Module
- gemeinsame Desktop-Mechaniken wie Fenster, Tray, globale Persistenz und Debug-Infrastruktur liegen im Desktop-Core
- Module und andere Apps sollen keine eigene Grund-Debug-Oberflaeche im Stil eines separaten globalen Debuggers bauen
- fuer Debugging im Desktop gilt das zentrale Admin-Debug-Widget neben der Uhr mit eigenem Debug-Fenster als Standard
- Debug-Events aus Modulen sollen in den gemeinsamen Desktop-Debug-Bus geschrieben werden, damit sie im globalen Debug-Fenster sichtbar sind
- wenn das globale Debug-Fenster geschlossen ist, sollen Module kein dauerhaft aktives Live-Debugging erzwingen
- app-spezifische Debug-Darstellungen sind nur zulaessig, wenn sie fachliche Zusatzinformationen zeigen, die ueber den globalen Stream hinausgehen
## Pflegehinweis
Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern.
Wichtige Inhalte aus dieser Datei muessen ebenfalls zentral gepflegt werden in:
- [CONTENT.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/CONTENT.md)
- [WEITERENTWICKLUNG.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/docs/WEITERENTWICKLUNG.md)

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
header('Content-Type: application/json; charset=utf-8');
try {
ModuleHttp::requireDesktopAccess(dirname(__DIR__, 3), true);
require_auth();
$user = auth_user() ?? [];
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
if ($instrumentId <= 0) {
echo json_encode(['ok' => false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = module_fn('boersenchecker', 'pdo');
module_fn('boersenchecker', 'ensure_schema');
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
$positionTable = module_fn('boersenchecker', 'table', 'positions');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$stmt = $pdo->prepare(
'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency
FROM ' . $instrumentTable . ' i
INNER JOIN ' . $positionTable . ' p ON p.instrument_id = i.id
WHERE i.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $instrumentId,
'owner_sub' => $ownerSub,
]);
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($instrument)) {
echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE);
exit;
}
$quoteStmt = $pdo->prepare(
'SELECT id, price, currency, quoted_at, source, created_at
FROM ' . $quoteTable . '
WHERE instrument_id = :instrument_id
ORDER BY quoted_at ASC, created_at ASC, id ASC'
);
$quoteStmt->execute([
'instrument_id' => $instrumentId,
]);
$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($quotes === []) {
echo json_encode([
'ok' => false,
'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$dailyMap = [];
foreach ($quotes as $quote) {
$localDate = trim((string) module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($quote['quoted_at'] ?? ''),
(string) ($quote['source'] ?? ''),
'Y-m-d'
));
$localDateTime = trim((string) module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($quote['quoted_at'] ?? ''),
(string) ($quote['source'] ?? ''),
'Y-m-d H:i:s'
));
if ($localDate === '' || !is_numeric($quote['price'] ?? null)) {
continue;
}
$point = [
'date' => $localDate,
'close' => (float) $quote['price'],
'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))),
'quoted_at' => $localDateTime,
'source' => (string) ($quote['source'] ?? ''),
];
if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) {
$dailyMap[$localDate] = $point;
}
}
$daily = array_values($dailyMap);
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
if ($daily === []) {
echo json_encode([
'ok' => false,
'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$aggregate = static function (array $points, string $format): array {
$result = [];
$timezone = new DateTimeZone(nexus_display_timezone_name());
foreach ($points as $point) {
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone);
if (!$date instanceof DateTimeImmutable) {
continue;
}
$bucket = $date->format($format);
$result[$bucket] = $point;
}
return array_values($result);
};
echo json_encode([
'ok' => true,
'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))),
'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))),
'instrument_name' => (string) ($instrument['name'] ?? ''),
'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))),
'daily' => $daily,
'weekly' => $aggregate($daily, 'o-W'),
'monthly' => $aggregate($daily, 'Y-m'),
'source' => 'database:quotes',
'source_label' => 'Lokale Kurshistorie',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'message' => 'Chartdaten konnten nicht geladen werden.',
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/boersenchecker/index.php/';
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
if ($relativePath === '') {
$relativePath = str_starts_with($requestPath, $prefix)
? substr($requestPath, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
}
$normalized = strtolower(trim($relativePath, '/'));
if ($normalized === 'v1/chart-data') {
require __DIR__ . '/chart_data.php';
return;
}
if ($normalized === 'v1/cron/refresh-quotes' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
header('Content-Type: application/json; charset=utf-8');
try {
$result = module_fn('boersenchecker', 'scheduled_refresh_quotes', [
'trigger_source' => 'cron',
]);
http_response_code(!empty($result['ok']) ? 200 : 500);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $exception) {
http_response_code(500);
echo json_encode([
'ok' => false,
'message' => $exception->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return;
}
if ($normalized === 'v1/widget/refresh-quotes' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
header('Content-Type: application/json; charset=utf-8');
try {
$result = module_fn('boersenchecker', 'scheduled_refresh_quotes', [
'trigger_source' => 'widget',
]);
http_response_code(!empty($result['ok']) ? 200 : 500);
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $exception) {
http_response_code(500);
echo json_encode([
'ok' => false,
'message' => $exception->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return;
}
if ($normalized === 'v1/widget/status' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET') {
header('Content-Type: application/json; charset=utf-8');
try {
$pdo = module_fn('boersenchecker', 'pdo');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
$positionTable = module_fn('boersenchecker', 'table', 'positions');
$settings = modules()->settings('boersenchecker');
$latestAt = $pdo->query('SELECT MAX(quoted_at) FROM ' . $quoteTable)->fetchColumn();
$countStmt = $pdo->query(
'SELECT COUNT(DISTINCT i.id)
FROM ' . $positionTable . ' p
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
WHERE i.symbol IS NOT NULL
AND i.symbol <> \'\''
);
$trackedCount = (int) ($countStmt->fetchColumn() ?: 0);
$statusText = $latestAt
? 'Letzter Kursabruf: ' . date('d.m.Y H:i', strtotime((string) $latestAt)) . ' · ' . $trackedCount . ' Titel'
: 'Noch keine gespeicherten Kurse vorhanden.';
echo json_encode([
'ok' => true,
'data' => [
'status_text' => $statusText,
'latest_quoted_at' => $latestAt,
'tracked_instruments' => $trackedCount,
'report_currency' => (string) ($settings['report_currency'] ?? 'EUR'),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $exception) {
http_response_code(500);
echo json_encode([
'ok' => false,
'message' => $exception->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return;
}
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'message' => 'Ressource nicht gefunden.',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,126 @@
(function () {
'use strict';
function toPartialUrl(input, fallbackView) {
const url = new URL(input, window.location.origin);
if (!url.searchParams.has('view') && fallbackView) {
url.searchParams.set('view', fallbackView);
}
url.searchParams.set('partial', '1');
return url;
}
async function fetchHtml(url, options) {
const response = await fetch(url.toString(), {
credentials: 'same-origin',
headers: {
Accept: 'text/html',
'X-Requested-With': 'XMLHttpRequest'
},
...options
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
function enhanceHost(host, state) {
host.querySelectorAll('a[href]').forEach((link) => {
const href = link.getAttribute('href') || '';
if (!href.startsWith('/apps/boersenchecker')) {
return;
}
link.addEventListener('click', (event) => {
event.preventDefault();
state.load(href);
});
});
host.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const method = String(form.getAttribute('method') || 'get').toUpperCase();
const action = form.getAttribute('action') || state.currentUrl.toString();
const formData = new FormData(form);
const url = toPartialUrl(action, state.defaultView);
try {
if (method === 'GET') {
const next = new URL(url.toString());
next.search = '';
next.searchParams.set('partial', '1');
for (const [key, value] of formData.entries()) {
next.searchParams.append(key, String(value));
}
await state.load(next.toString());
return;
}
const html = await fetchHtml(url, {
method,
body: formData
});
state.render(html, url);
} catch (error) {
state.renderError(error);
}
});
});
}
window.initBoersencheckerApp = function initBoersencheckerApp(host, options) {
if (!(host instanceof HTMLElement)) {
return;
}
const entryRoute = String(options?.entryRoute || '/apps/boersenchecker/index.php');
const defaultView = String(options?.defaultView || 'overview');
const state = {
defaultView,
currentUrl: toPartialUrl(entryRoute, defaultView),
async load(targetUrl) {
const url = toPartialUrl(targetUrl, defaultView);
this.currentUrl = url;
host.innerHTML = '<div class="window-app-loading"><p>Börsenchecker wird geladen...</p></div>';
try {
const html = await fetchHtml(url);
this.render(html, url);
} catch (error) {
this.renderError(error);
}
},
render(html, url) {
this.currentUrl = url;
host.innerHTML = html;
enhanceHost(host, this);
if (typeof window.initBoersencheckerCharts === 'function') {
try {
window.initBoersencheckerCharts(host);
} catch (error) {
const message = error instanceof Error ? error.message : 'Chart-Initialisierung fehlgeschlagen.';
const statusNode = host.querySelector('[data-bc-chart-status]');
const chartNode = host.querySelector('[data-bc-chart]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = message;
}
if (chartNode instanceof HTMLElement) {
chartNode.innerHTML = `<div class="muted">${message}</div>`;
}
}
}
},
renderError(error) {
const message = error instanceof Error ? error.message : 'Börsenchecker konnte nicht geladen werden.';
host.innerHTML = `<div class="window-app-error-state"><p>${message}</p></div>`;
}
};
state.load(entryRoute);
};
})();

View File

@@ -0,0 +1,402 @@
.bc-page {
--bc-accent: var(--brand-accent);
--bc-accent-strong: var(--brand-accent-2);
--bc-ink: var(--text);
--bc-text: var(--text);
--bc-muted: var(--muted);
--bc-line: var(--line);
--bc-panel: rgba(255, 255, 255, 0.78);
--bc-panel-soft: rgba(248, 252, 252, 0.92);
--bc-positive: #137333;
--bc-negative: #c62828;
color: var(--bc-text);
}
.bc-frame {
height: 100%;
min-height: 0;
}
.bc-sidebar {
gap: 0;
}
.bc-brand {
margin-bottom: 18px;
}
.bc-nav-list {
gap: 12px;
}
.bc-nav-button {
text-decoration: none;
}
.bc-nav-button strong {
display: block;
margin-bottom: 4px;
}
.bc-main {
background: transparent;
}
.bc-panel-shell {
max-width: 1280px;
}
.bc-hero {
align-items: flex-start;
}
.bc-pill-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.bc-pill {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--bc-accent) 34%, transparent);
background: color-mix(in srgb, var(--bc-accent) 10%, white);
color: var(--bc-text);
font-size: 0.86rem;
font-weight: 700;
}
.bc-page {
display: grid;
gap: 16px;
}
.bc-text {
color: var(--bc-muted);
margin: 0;
}
.bc-section-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.bc-section-title {
margin: 0;
font-size: 1.45rem;
line-height: 1.15;
}
.bc-section-head p,
.bc-section-copy {
color: var(--bc-muted);
margin: 8px 0 0;
}
.bc-form-card,
.bc-panel,
.bc-stat,
.bc-chart-card,
.bc-position-row {
border: 1px solid var(--bc-line);
border-radius: 22px;
background: var(--bc-panel);
box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06);
}
.bc-form-card,
.bc-panel,
.bc-chart-card {
padding: 18px;
}
.bc-stat {
padding: 16px;
}
.bc-field-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bc-muted);
}
.bc-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.bc-button,
.bc-tabs a,
.bc-page button,
.bc-page input,
.bc-page select,
.bc-page textarea {
font: inherit;
}
.bc-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
text-decoration: none;
transition: 160ms ease;
}
.bc-button:hover {
transform: translateY(-1px);
}
.bc-button--tab {
background: rgba(255, 255, 255, 0.94);
color: var(--bc-ink);
font-weight: 700;
}
.bc-button--tab-active {
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
color: #fff7fb;
font-weight: 700;
}
.bc-button--primary {
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
color: #fff7fb;
font-weight: 700;
}
.bc-button--secondary {
background: rgba(255, 255, 255, 0.94);
color: var(--bc-ink);
font-weight: 700;
}
.bc-button--ghost {
background: color-mix(in srgb, var(--bc-accent) 14%, transparent);
border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent);
color: var(--bc-accent);
font-weight: 700;
}
.bc-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.bc-alert {
padding: 16px 18px;
border-radius: 20px;
border: 1px solid transparent;
}
.bc-alert--error {
background: rgba(127, 29, 29, 0.28);
border-color: rgba(252, 165, 165, 0.24);
color: #fecaca;
}
.bc-alert--success {
background: rgba(6, 78, 59, 0.28);
border-color: rgba(134, 239, 172, 0.24);
color: #bbf7d0;
}
.bc-toolbar,
.bc-overview-grid,
.bc-card-grid {
display: grid;
gap: 14px;
}
.bc-toolbar {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.bc-overview-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.bc-card-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.bc-stat-value {
margin-top: 8px;
font-size: 1.42rem;
font-weight: 700;
}
.bc-chart-shell {
position: relative;
min-height: 360px;
overflow: hidden;
}
.bc-chart-svg {
width: 100%;
height: 340px;
display: block;
}
.bc-chart-path {
fill: none;
stroke: var(--bc-accent);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent));
}
.bc-chart-area {
fill: url(#bc-chart-fill);
}
.bc-chart-grid line {
stroke: rgba(16, 33, 43, 0.08);
stroke-dasharray: 4 6;
}
.bc-range-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.bc-range-button {
border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent);
background: rgba(255, 255, 255, 0.76);
color: var(--bc-text);
padding: 8px 12px;
border-radius: 999px;
cursor: pointer;
transition: transform .18s ease, background .18s ease, border-color .18s ease;
}
.bc-range-button:hover,
.bc-range-button[aria-pressed="true"] {
transform: translateY(-1px);
background: color-mix(in srgb, var(--bc-accent) 18%, transparent);
border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent);
}
.bc-panel-fade {
animation: bcPanelFade .35s ease;
}
@keyframes bcPanelFade {
from { opacity: 0; transform: translateY(8px) scale(.99); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.bc-position-list {
display: grid;
gap: 14px;
}
.bc-position-row {
display: grid;
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr));
gap: 14px;
align-items: center;
padding: 16px 18px;
}
.bc-performance {
font-weight: 700;
}
.bc-performance.is-positive {
color: var(--bc-positive);
}
.bc-performance.is-negative {
color: var(--bc-negative);
}
.bc-pill-soft {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--bc-accent) 10%, transparent);
color: var(--bc-text);
font-size: .85rem;
}
.bc-table-shell {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--bc-line);
}
.bc-table {
width: 100%;
border-collapse: collapse;
}
.bc-table th,
.bc-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.06);
vertical-align: top;
}
.bc-table thead {
background: rgba(255,255,255,0.04);
}
.bc-page .setup-field {
display: grid;
gap: 6px;
}
.bc-page input,
.bc-page select,
.bc-page textarea {
width: 100%;
border: 1px solid var(--bc-line);
border-radius: 14px;
padding: 10px 12px;
background: var(--surface-strong);
color: var(--bc-text);
}
.bc-page input::placeholder,
.bc-page textarea::placeholder {
color: color-mix(in srgb, var(--bc-muted) 70%, transparent);
}
.bc-page a {
color: inherit;
}
.bc-page .muted {
color: var(--bc-muted);
}
@media (max-width: 980px) {
.bc-frame {
grid-template-columns: 1fr;
}
.bc-hero-top {
grid-template-columns: 1fr;
}
.bc-position-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,175 @@
(function () {
'use strict';
function initBoersencheckerCharts(root) {
const scope = root instanceof Element ? root : document;
const app = scope.matches?.('[data-bc-home]') ? scope : scope.querySelector('[data-bc-home]');
if (!app || app.dataset.bcChartBound === '1') return;
app.dataset.bcChartBound = '1';
const chartShell = app.querySelector('[data-bc-chart]');
const instrumentSelect = app.querySelector('[data-bc-instrument]');
const instrumentNameNode = app.querySelector('[data-bc-instrument-name]');
const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]');
const rangeButtons = Array.from(app.querySelectorAll('[data-range]'));
const statusNode = app.querySelector('[data-bc-chart-status]');
const summaryNode = app.querySelector('[data-bc-chart-summary]');
const endpoint = app.getAttribute('data-bc-chart-endpoint') || app.getAttribute('data-chart-endpoint') || '';
const instrumentsScript = app.querySelector('[data-bc-instruments-json]');
const instrumentMap = new Map();
if (instrumentsScript?.textContent) {
try {
const items = JSON.parse(instrumentsScript.textContent);
if (Array.isArray(items)) {
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item));
}
} catch (_error) {}
}
let activeRange = '1m';
let currentPayload = null;
function pointsForRange(payload, range) {
if (!payload) return [];
const daily = payload.daily || [];
const weekly = payload.weekly || [];
const monthly = payload.monthly || [];
switch (range) {
case '1d': return daily.slice(-2);
case '5d': return daily.slice(-5);
case '1m': return daily.slice(-22);
case '3m': return daily.slice(-66);
case '6m': return weekly.slice(-26);
case '1y': return weekly.slice(-52);
case '5y': return monthly.slice(-60);
default: return daily.slice(-22);
}
}
function renderChart(points) {
if (!chartShell) return;
chartShell.classList.remove('bc-panel-fade');
void chartShell.offsetWidth;
chartShell.classList.add('bc-panel-fade');
if (!points || points.length === 0) {
chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
}
const values = points.map((point) => Number(point.close || 0));
const min = Math.min(...values);
const max = Math.max(...values);
const width = 920;
const height = 340;
const paddingX = 24;
const paddingY = 28;
const usableWidth = width - paddingX * 2;
const usableHeight = height - paddingY * 2;
const spread = max - min || 1;
const coords = points.map((point, index) => {
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1));
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight;
return { x, y };
});
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' ');
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`;
const grid = [0, 1, 2, 3].map((step) => {
const y = paddingY + (usableHeight * step / 3);
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
}).join('');
chartShell.innerHTML = `
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<defs>
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
</linearGradient>
</defs>
<g class="bc-chart-grid">${grid}</g>
<path class="bc-chart-area" d="${area}"></path>
<path class="bc-chart-path" d="${path}"></path>
</svg>
`;
const first = values[0];
const last = values[values.length - 1];
const delta = last - first;
const percent = first !== 0 ? (delta / first) * 100 : 0;
if (summaryNode) {
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
}
}
async function loadChart() {
const instrumentId = instrumentSelect ? instrumentSelect.value : '';
if (!instrumentId || !endpoint) {
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
if (summaryNode) summaryNode.textContent = '-';
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
}
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
try {
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' }
});
const responseText = await response.text();
let payload = null;
try {
payload = JSON.parse(responseText);
} catch (_error) {
const preview = responseText.trim().slice(0, 160);
throw new Error(preview !== '' ? `Chart-API liefert kein JSON: ${preview}` : 'Chart-API liefert kein JSON.');
}
if (!payload.ok) {
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
}
currentPayload = payload;
renderChart(pointsForRange(payload, activeRange));
if (statusNode) {
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
const instrumentRef = payload.symbol || payload.isin || '';
statusNode.textContent = instrumentRef
? `Quelle: ${sourceLabel} | ${instrumentRef}`
: `Quelle: ${sourceLabel}`;
}
} catch (error) {
currentPayload = null;
if (chartShell) {
chartShell.innerHTML = `<div class="muted">${error instanceof Error ? error.message : 'Chartdaten konnten nicht geladen werden.'}</div>`;
}
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
}
}
rangeButtons.forEach((button) => {
button.addEventListener('click', () => {
activeRange = button.getAttribute('data-range') || '1m';
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
renderChart(pointsForRange(currentPayload, activeRange));
});
});
if (instrumentSelect) {
instrumentSelect.addEventListener('change', () => {
const meta = instrumentMap.get(String(instrumentSelect.value));
if (meta) {
if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
}
loadChart();
});
}
loadChart();
}
window.initBoersencheckerCharts = initBoersencheckerCharts;
initBoersencheckerCharts(document);
})();

View File

@@ -0,0 +1,995 @@
<?php
declare(strict_types=1);
use App\ModuleConfigException;
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\Boersenchecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
$moduleName = 'boersenchecker';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
$prefix = 'boersencheck_';
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return $prefix . $sanitized;
});
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO {
$settings = modules()->settings($moduleName);
$useSeparate = !empty($settings['use_separate_db']);
if ($useSeparate) {
$module = modules()->get($moduleName);
$fallback = $module['db_defaults'] ?? [];
return modules()->modulePdo($moduleName, $fallback);
}
$base = app()->basePdo();
if ($base instanceof \PDO) {
return $base;
}
throw new ModuleConfigException(
$moduleName,
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
);
});
$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void {
$pdo = module_fn($moduleName, 'pdo');
$table = static fn (string $name): string => module_fn($moduleName, 'table', $name);
$driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
$portfolioTable = $table('portfolios');
$instrumentTable = $table('instruments');
$positionTable = $table('positions');
$quoteTable = $table('quotes');
if ($driver === 'pgsql') {
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
id SERIAL PRIMARY KEY,
owner_sub VARCHAR(190) NOT NULL,
name VARCHAR(190) NOT NULL,
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
id SERIAL PRIMARY KEY,
isin VARCHAR(32) NULL,
wkn VARCHAR(32) NULL,
symbol VARCHAR(32) NULL,
name VARCHAR(255) NOT NULL,
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
market VARCHAR(120) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
id SERIAL PRIMARY KEY,
owner_sub VARCHAR(190) NOT NULL,
portfolio_id INTEGER NOT NULL,
instrument_id INTEGER NOT NULL,
quantity NUMERIC(20,6) NOT NULL,
purchase_price NUMERIC(20,8) NOT NULL,
purchase_currency VARCHAR(10) NOT NULL,
purchase_date DATE NOT NULL,
fees NUMERIC(20,8) NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
id SERIAL PRIMARY KEY,
instrument_id INTEGER NOT NULL,
price NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
quoted_at TIMESTAMP NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
} elseif ($driver === 'mysql') {
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
owner_sub VARCHAR(190) NOT NULL,
name VARCHAR(190) NOT NULL,
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$portfolioTable}_owner_idx (owner_sub)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
isin VARCHAR(32) NULL,
wkn VARCHAR(32) NULL,
symbol VARCHAR(32) NULL,
name VARCHAR(255) NOT NULL,
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
market VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY {$instrumentTable}_isin_uniq (isin),
KEY {$instrumentTable}_symbol_idx (symbol)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
owner_sub VARCHAR(190) NOT NULL,
portfolio_id INTEGER NOT NULL,
instrument_id INTEGER NOT NULL,
quantity DECIMAL(20,6) NOT NULL,
purchase_price DECIMAL(20,8) NOT NULL,
purchase_currency VARCHAR(10) NOT NULL,
purchase_date DATE NOT NULL,
fees DECIMAL(20,8) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$positionTable}_owner_idx (owner_sub),
KEY {$positionTable}_portfolio_idx (portfolio_id),
KEY {$positionTable}_instrument_idx (instrument_id)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
instrument_id INTEGER NOT NULL,
price DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
quoted_at DATETIME NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at)
)");
} else {
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_sub VARCHAR(190) NOT NULL,
name VARCHAR(190) NOT NULL,
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
isin VARCHAR(32) NULL,
wkn VARCHAR(32) NULL,
symbol VARCHAR(32) NULL,
name VARCHAR(255) NOT NULL,
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
market VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_sub VARCHAR(190) NOT NULL,
portfolio_id INTEGER NOT NULL,
instrument_id INTEGER NOT NULL,
quantity DECIMAL(20,6) NOT NULL,
purchase_price DECIMAL(20,8) NOT NULL,
purchase_currency VARCHAR(10) NOT NULL,
purchase_date DATE NOT NULL,
fees DECIMAL(20,8) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instrument_id INTEGER NOT NULL,
price DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
quoted_at DATETIME NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
}
});
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
try {
return module_fn('fx-rates', 'service');
} catch (\Throwable) {
return null;
}
}
return null;
});
$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array {
$service = module_fn('boersenchecker', 'fx_service');
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
return [
'ok' => false,
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
];
}
try {
$result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR');
return [
'ok' => true,
'message' => !empty($result['reused'])
? 'Vorhandene FX-Daten weiterverwendet.'
: 'FX-Daten aktualisiert.',
'result' => $result,
];
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
];
}
});
$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function (
string $baseCurrency = 'EUR',
array $currencies = [],
float $maxAgeHours = 6.0
): array {
$service = module_fn('boersenchecker', 'fx_service');
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
return [
'ok' => false,
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
];
}
$baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR';
$currencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
$currencies
), static fn (string $code): bool => $code !== '')));
try {
$result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
return [
'ok' => true,
'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.',
'result' => $result,
'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null,
'reused' => !empty($result['reused']),
];
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
];
}
});
$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string {
$source = trim($source) !== '' ? trim($source) : 'manual';
if ($fetchId === null || $fetchId <= 0) {
return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
}
$source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
return $source . '|fx_fetch:' . $fetchId;
});
$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int {
$source = trim((string) $source);
if ($source === '') {
return null;
}
if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) {
$fetchId = (int) ($matches[1] ?? 0);
return $fetchId > 0 ? $fetchId : null;
}
return null;
});
$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function (
?float $amount,
?string $fromCurrency,
?string $toCurrency,
?int $fetchId = null
): ?float {
if ($amount === null) {
return null;
}
$from = strtoupper(trim((string) $fromCurrency));
$to = strtoupper(trim((string) $toCurrency));
if ($from === '' || $to === '') {
return null;
}
if ($from === $to) {
return $amount;
}
$service = module_fn('boersenchecker', 'fx_service');
if (!$service) {
return null;
}
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) {
try {
$snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]);
if (is_array($snapshot)) {
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null);
$toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null);
if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) {
return $amount * ($toRate / $fromRate);
}
}
} catch (\Throwable) {
}
}
if (!method_exists($service, 'convert')) {
return null;
}
try {
$value = $service->convert($amount, $from, $to);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
});
$mm->registerFunction($moduleName, 'alpha_vantage_request', static function (
string $functionName,
array $params = []
): array {
$settings = modules()->settings('boersenchecker');
$apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? ''));
$timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12);
$timeout = $timeout > 0 ? $timeout : 12;
if ($apiKey === '') {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'params' => $params,
],
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
]);
return [
'ok' => false,
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
];
}
$url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([
'function' => $functionName,
'apikey' => $apiKey,
], $params), '', '&', PHP_QUERY_RFC3986);
$responseBody = null;
$httpCode = 0;
$curlError = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch !== false) {
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$responseBody = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
}
}
if (!is_string($responseBody) || $responseBody === '') {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'header' => "Accept: application/json\r\n",
],
]);
$responseBody = @file_get_contents($url, false, $context);
}
if (!is_string($responseBody) || $responseBody === '') {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'curl_error' => $curlError,
'body' => null,
],
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.',
]);
return [
'ok' => false,
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.'
. ($curlError !== '' ? ' ' . $curlError : '')
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
];
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'body_preview' => substr($responseBody, 0, 4000),
],
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
]);
return [
'ok' => false,
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
'raw_body' => $responseBody,
];
}
foreach (['Error Message', 'Information', 'Note'] as $errorKey) {
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'body' => $decoded,
],
'message' => trim((string) $decoded[$errorKey]),
]);
return [
'ok' => false,
'message' => trim((string) $decoded[$errorKey]),
'raw' => $decoded,
];
}
}
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:response',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'body' => $decoded,
],
]);
return [
'ok' => true,
'data' => $decoded,
];
});
$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone {
return new \DateTimeZone(nexus_display_timezone_name());
});
$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string {
if (is_numeric($value)) {
return gmdate('Y-m-d H:i:s', (int) $value);
}
$raw = trim((string) $value);
if ($raw === '') {
return gmdate('Y-m-d H:i:s');
}
try {
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
} catch (\Throwable) {
$timestamp = strtotime($raw);
return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s');
}
});
$mm->registerFunction($moduleName, 'format_datetime_for_display', static function (
?string $value,
?string $source = null,
string $format = 'Y-m-d H:i:s'
): string {
$raw = trim((string) $value);
if ($raw === '') {
return '';
}
$displayTimezone = new \DateTimeZone(nexus_display_timezone_name());
$source = trim((string) $source);
if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) {
$date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC'));
if (!$date instanceof \DateTimeImmutable) {
try {
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
} catch (\Throwable) {
return $raw;
}
}
return $date->setTimezone($displayTimezone)->format($format);
}
if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) {
try {
return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format);
} catch (\Throwable) {
return $raw;
}
}
return str_replace('T', ' ', $raw);
});
$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string {
return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i');
});
$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array {
$quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry;
$price = $quote['05. price'] ?? null;
if (!is_numeric($price)) {
return null;
}
return [
'symbol' => trim((string) ($quote['01. symbol'] ?? '')),
'price' => (float) $price,
'currency' => '',
'fetched_at' => gmdate('Y-m-d H:i:s'),
'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')),
'source' => 'alphavantage:global_quote',
'raw' => $quote,
];
});
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array {
$symbol = strtoupper(trim($symbol));
if ($symbol === '') {
return [
'ok' => false,
'message' => 'Kein Symbol hinterlegt.',
];
}
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [
'symbol' => $symbol,
]);
if (empty($response['ok'])) {
return $response;
}
$quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? []));
if (!is_array($quote)) {
return [
'ok' => false,
'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.',
];
}
return ['ok' => true] + $quote;
});
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array {
$quotes = [];
$errors = [];
foreach ($instruments as $instrument) {
if (!is_array($instrument)) {
continue;
}
$instrumentId = (int) ($instrument['id'] ?? 0);
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
if ($instrumentId <= 0 || $symbol === '') {
continue;
}
$result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
if (empty($result['ok'])) {
$errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.');
continue;
}
$quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId];
}
return [
'ok' => true,
'quotes' => $quotes,
'errors' => $errors,
'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.',
];
});
$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array {
$pdo = module_fn('boersenchecker', 'pdo');
$settings = modules()->settings('boersenchecker');
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
$positionTable = module_fn('boersenchecker', 'table', 'positions');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
$minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
if ($minIntervalMinutes <= 0) {
$minIntervalMinutes = 60;
}
$stmt = $pdo->query(
'SELECT DISTINCT
i.id,
i.name,
i.symbol,
i.quote_currency
FROM ' . $positionTable . ' p
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
WHERE i.symbol IS NOT NULL
AND i.symbol <> \'\'
ORDER BY i.name ASC'
);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
return [
'ok' => true,
'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.',
];
}
$instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows));
$instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0));
$latestQuotes = [];
if ($instrumentIds !== []) {
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$latestStmt = $pdo->prepare(
'SELECT *
FROM ' . $quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
AND source LIKE ?
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$latestStmt->execute([...$instrumentIds, 'alphavantage:%']);
foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$instrumentId = (int) ($row['instrument_id'] ?? 0);
if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) {
$latestQuotes[$instrumentId] = $row;
}
}
}
$reused = 0;
$candidates = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$latest = $latestQuotes[$instrumentId] ?? null;
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) {
$reused++;
continue;
}
$candidates[] = $row;
}
if ($candidates === []) {
return [
'ok' => true,
'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.',
];
}
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))),
$candidates
), static fn (string $code): bool => $code !== '')));
$fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6));
if (empty($fxResult['ok'])) {
return $fxResult;
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates);
if (empty($bulkResult['ok'])) {
return [
'ok' => false,
'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'),
];
}
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
$errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : [];
$updated = 0;
foreach ($candidates as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$quote = $quotes[$instrumentId] ?? null;
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
continue;
}
$storeResult = module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $quote['price'],
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency,
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
(string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
$updated++;
} else {
$reused++;
}
}
$message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.';
if ($errors !== []) {
$message .= ' ' . implode(' | ', array_slice($errors, 0, 3));
}
module_debug_push('boersenchecker', [
'label' => 'Intervall-Aufgabe',
'type' => 'scheduler:run',
'task' => 'auto_refresh_quotes',
'context' => $context,
'message' => $message,
]);
return [
'ok' => $errors === [],
'message' => $message,
'updated' => $updated,
'reused' => $reused,
'errors' => $errors,
];
});
$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array {
$keywords = trim($keywords);
if ($keywords === '') {
return [
'ok' => false,
'message' => 'Bitte Suchbegriff angeben.',
'results' => [],
];
}
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [
'keywords' => $keywords,
]);
if (empty($response['ok'])) {
return $response + ['results' => []];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : [];
$results = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$symbol = trim((string) ($item['1. symbol'] ?? ''));
$name = trim((string) ($item['2. name'] ?? ''));
$type = trim((string) ($item['3. type'] ?? ''));
$region = trim((string) ($item['4. region'] ?? ''));
$currency = strtoupper(trim((string) ($item['8. currency'] ?? '')));
$matchScore = trim((string) ($item['9. matchScore'] ?? ''));
if ($symbol === '' && $name === '') {
continue;
}
$results[] = [
'symbol' => $symbol,
'name' => $name,
'isin' => '',
'type' => $type,
'region' => $region,
'currency' => $currency,
'match_score' => $matchScore,
'raw' => $item,
];
}
return [
'ok' => true,
'message' => count($results) . ' Treffer gefunden.',
'results' => $results,
];
});
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array {
$symbol = strtoupper(trim($symbol));
if ($symbol === '') {
return ['ok' => false, 'message' => 'Kein Symbol angegeben.'];
}
$cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
$cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json';
$decoded = null;
if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) {
$cached = file_get_contents($cachePath);
$decoded = is_string($cached) ? json_decode($cached, true) : null;
}
if (!is_array($decoded)) {
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [
'symbol' => $symbol,
'outputsize' => 'full',
]);
if (empty($response['ok'])) {
return $response;
}
$decoded = is_array($response['data'] ?? null) ? $response['data'] : [];
@file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$rows = is_array($decoded['Time Series (Daily)'] ?? null)
? $decoded['Time Series (Daily)']
: (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []);
$daily = [];
foreach ($rows as $date => $row) {
if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) {
continue;
}
$close = $row['5. adjusted close'] ?? $row['4. close'] ?? null;
if (!is_numeric($close)) {
continue;
}
$daily[] = [
'date' => $date,
'close' => (float) $close,
];
}
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
if ($daily === []) {
return [
'ok' => false,
'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.',
];
}
$aggregate = static function (array $points, string $format): array {
$result = [];
foreach ($points as $point) {
$bucket = date($format, strtotime((string) $point['date']) ?: time());
$result[$bucket] = $point;
}
return array_values($result);
};
return [
'ok' => true,
'symbol' => $symbol,
'daily' => $daily,
'weekly' => $aggregate($daily, 'o-W'),
'monthly' => $aggregate($daily, 'Y-m'),
'source' => 'alphavantage:time_series_daily_adjusted',
];
});
$mm->registerFunction($moduleName, 'store_market_quote', static function (
int $instrumentId,
float $price,
string $currency,
string $quotedAt,
string $source
): array {
$pdo = module_fn('boersenchecker', 'pdo');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$quotedAt = trim($quotedAt);
$currency = strtoupper(trim($currency)) ?: 'EUR';
$source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote';
$checkStmt = $pdo->prepare(
'SELECT id
FROM ' . $quoteTable . '
WHERE instrument_id = :instrument_id
AND price = :price
AND currency = :currency
AND quoted_at = :quoted_at
AND source = :source
LIMIT 1'
);
$checkStmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
$existingId = (int) $checkStmt->fetchColumn();
if ($existingId > 0) {
module_debug_push('boersenchecker', [
'label' => 'Quote Store',
'type' => 'quote:reuse',
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
'message' => 'Identischer Snapshot bereits vorhanden.',
]);
return ['ok' => true, 'inserted' => false, 'id' => $existingId];
}
$insertStmt = $pdo->prepare(
'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$insertStmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
$insertedId = (int) $pdo->lastInsertId();
module_debug_push('boersenchecker', [
'label' => 'Quote Store',
'type' => 'quote:insert',
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
'inserted_id' => $insertedId,
'message' => 'Neuer Snapshot gespeichert.',
]);
return ['ok' => true, 'inserted' => true, 'id' => $insertedId];
});

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\ConfigLoader;
$projectRoot = dirname(__DIR__, 3);
$baseDb = ConfigLoader::load($projectRoot, 'base_db');
$baseDbConfig = (bool) ($baseDb['enabled'] ?? false) && is_array($baseDb['db'] ?? null)
? $baseDb['db']
: ConfigLoader::load($projectRoot, 'db_settings_basic');
return [
'use_separate_db' => filter_var(getenv('BOERSENCHECKER_USE_SEPARATE_DB') ?: 'false', FILTER_VALIDATE_BOOL),
'db' => [
'driver' => getenv('BOERSENCHECKER_DB_DRIVER') ?: (string) ($baseDbConfig['driver'] ?? 'pgsql'),
'host' => getenv('BOERSENCHECKER_DB_HOST') ?: (string) ($baseDbConfig['host'] ?? 'localhost'),
'port' => (int) (getenv('BOERSENCHECKER_DB_PORT') ?: (int) ($baseDbConfig['port'] ?? 5432)),
'dbname' => getenv('BOERSENCHECKER_DB_NAME') ?: (string) ($baseDbConfig['dbname'] ?? ''),
'schema' => getenv('BOERSENCHECKER_DB_SCHEMA') ?: (string) ($baseDbConfig['schema'] ?? 'public'),
'user' => getenv('BOERSENCHECKER_DB_USER') ?: (string) ($baseDbConfig['user'] ?? ''),
'password' => getenv('BOERSENCHECKER_DB_PASSWORD') ?: (string) ($baseDbConfig['password'] ?? ''),
],
'report_currency' => getenv('BOERSENCHECKER_REPORT_CURRENCY') ?: 'EUR',
'fx_max_age_hours' => (float) (getenv('BOERSENCHECKER_FX_MAX_AGE_HOURS') ?: 6),
'alpha_vantage_api_key' => getenv('BOERSENCHECKER_ALPHA_VANTAGE_API_KEY') ?: (getenv('ALPHA_VANTAGE_API_KEY') ?: ''),
'alpha_vantage_timeout_sec' => (int) (getenv('BOERSENCHECKER_ALPHA_VANTAGE_TIMEOUT_SEC') ?: 12),
'alpha_vantage_min_interval_minutes' => (int) (getenv('BOERSENCHECKER_ALPHA_VANTAGE_MIN_INTERVAL_MINUTES') ?: 60),
'auto_refresh_quotes_enabled' => filter_var(getenv('BOERSENCHECKER_AUTO_REFRESH_QUOTES_ENABLED') ?: 'false', FILTER_VALIDATE_BOOL),
'auto_refresh_quotes_interval_hours' => (int) (getenv('BOERSENCHECKER_AUTO_REFRESH_QUOTES_INTERVAL_HOURS') ?: 6),
];

View File

@@ -0,0 +1,15 @@
{
"eyebrow": "Modul",
"title": "Boersenchecker",
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
"actions": [
{ "label": "Desktop", "href": "/" },
{ "label": "Setup", "href": "/apps/boersenchecker/index.php?view=setup" }
],
"tabs": [
{ "label": "Ueberblick", "href": "/apps/boersenchecker/index.php?view=overview", "view": "overview" },
{ "label": "Depotverwaltung", "href": "/apps/boersenchecker/index.php?view=depotverwaltung", "view": "depotverwaltung" },
{ "label": "Aktienverwaltung", "href": "/apps/boersenchecker/index.php?view=aktienverwaltung", "view": "aktienverwaltung" },
{ "label": "Setup", "href": "/apps/boersenchecker/index.php?view=setup", "view": "setup" }
]
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
return [
'app_id' => 'boersenchecker',
'title' => (string) ($manifest['title'] ?? 'Börsenchecker'),
'icon' => 'BC',
'entry_route' => '/apps/boersenchecker/index.php',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1220,
'default_height' => 860,
'supports_widget' => false,
'supports_tray' => false,
'module_name' => 'finance',
'summary' => (string) ($manifest['description'] ?? 'Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.'),
'native_module' => [
'initializer' => 'initBoersencheckerApp',
'options' => [
'entryRoute' => '/apps/boersenchecker/index.php',
'defaultView' => 'overview',
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker.css') ? filemtime(__DIR__ . '/assets/boersenchecker.css') : '0'))],
],
'scripts' => [
['src' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker.js') ? filemtime(__DIR__ . '/assets/boersenchecker.js') : '0'))],
['src' => '/module-assets/index.php?module=boersenchecker&path=assets/boersenchecker-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/boersenchecker-native.js') ? filemtime(__DIR__ . '/assets/boersenchecker-native.js') : '0'))],
],
],
],
];

View File

@@ -0,0 +1,20 @@
# Börsenchecker
Importiertes Altmodul fuer Depotverwaltung, Aktienstammdaten, Kursverlauf, Alpha-Vantage-Abrufe und FX-Umrechnung.
## Einbindung
- Desktop-App ueber `custom/apps/boersenchecker/desktop.php`
- oeffentlicher App-Einstieg ueber `public/apps/boersenchecker/index.php`
- API-Endpunkt fuer Chartdaten ueber `public/api/boersenchecker/index.php?path=v1/chart-data`
## Konfiguration
- Standardwerte liegen in `config/module.php`
- gespeicherte Modul-Settings liegen unter `data/module-settings/boersenchecker.json`
- das Setup innerhalb der App bildet die alten Nexus-Setup-Felder ab
## Altlogik
- Fachlogik, Partials und Alpha-Vantage-/FX-Helfer stammen aus dem alten Nexus-Modul
- die Legacy-Kompatibilitaet wird ueber `src/ModulesCore/LegacyCompat.php` bereitgestellt

View File

@@ -0,0 +1,97 @@
{
"title": "Börsenchecker",
"version": "0.2.0",
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
"enabled_by_default": true,
"app_scope": "module",
"installable": true,
"desktop": {
"available": true,
"show_on_desktop": true,
"show_in_start_menu": true
},
"widgets": [
{
"widget_id": "boersenchecker-refresh",
"title": "Boersenkurse",
"icon": "BC",
"zone": "sidebar",
"default_enabled": false,
"supports_public_home": false,
"summary": "Kurse fuer hinterlegte Depot-Positionen manuell nachziehen.",
"content": "Widget fuer Schnellabruf des letzten Kursstatus und manuellen Marktrefresh.",
"launch_app_id": "boersenchecker",
"action_label": "App oeffnen",
"widget_type": "action-panel",
"status_api": "/api/boersenchecker/index.php?path=v1/widget/status",
"actions": [
{
"action_id": "refresh-quotes",
"label": "Kurse abrufen",
"endpoint": "/api/boersenchecker/index.php?path=v1/widget/refresh-quotes",
"method": "POST"
}
]
}
],
"cron_jobs": [
{
"job_id": "refresh-quotes",
"label": "Boersenkurse aktualisieren",
"description": "Laedt fuer alle aktiven Depot-Positionen neue Alpha-Vantage-Kurse.",
"endpoint_path": "/api/boersenchecker/index.php?path=v1/cron/refresh-quotes",
"method": "POST",
"payload": {
"trigger_source": "cron"
},
"default_enabled": false,
"default_cron": "15 */6 * * 1-5",
"lock_minutes": 20
}
],
"setup": {
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." },
{ "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." },
{ "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." },
{ "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." },
{ "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." },
{ "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." },
{ "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." }
]
},
"interval_tasks": [
{
"name": "auto_refresh_quotes",
"label": "Automatischer Kursabruf",
"callback": "scheduled_refresh_quotes",
"enabled_setting": "auto_refresh_quotes_enabled",
"interval_setting": "auto_refresh_quotes_interval_hours",
"default_enabled": false,
"default_interval_hours": 6,
"lock_minutes": 20
}
],
"db_defaults": {
"driver": "pgsql",
"host": "localhost",
"port": 5432,
"dbname": "",
"schema": "public",
"user": "",
"password": ""
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$isPartial = !empty($_GET['partial']);
$view = trim((string) ($_GET['view'] ?? 'overview'));
$allowedViews = ['overview', 'depotverwaltung', 'aktienverwaltung', 'setup'];
if (!in_array($view, $allowedViews, true)) {
$view = 'overview';
}
$viewMeta = [
'overview' => [
'label' => 'Ueberblick',
'title' => 'Depot-Ueberblick',
'description' => 'Kennzahlen, Kursverlauf und Performance fuer das ausgewaehlte Depot.',
'nav_description' => 'Marktueberblick, Positionen und gespeicherte Kurse.',
],
'depotverwaltung' => [
'label' => 'Depotverwaltung',
'title' => 'Depotverwaltung',
'description' => 'Depots, Positionen und manuelle Kursdaten zentral verwalten.',
'nav_description' => 'Depots pflegen, FX abrufen und Positionen bearbeiten.',
],
'aktienverwaltung' => [
'label' => 'Aktienverwaltung',
'title' => 'Aktienverwaltung',
'description' => 'Wertpapier-Stammdaten, Suche und Kursverlauf pro Aktie pflegen.',
'nav_description' => 'Ticker, ISIN, Markt und Kursverlauf pro Instrument.',
],
'setup' => [
'label' => 'Setup',
'title' => 'Setup',
'description' => 'API-Zugaenge, Datenbankoptionen und Modulvorgaben verwalten.',
'nav_description' => 'Schluessel, Timeouts und Modulkonfiguration speichern.',
],
];
$assetVersion = static function (string $relativePath): string {
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
$renderSetup = static function (): void {
$moduleName = 'boersenchecker';
$manifestPath = dirname(__DIR__) . '/module.json';
$manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
$manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
$fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
$settings = modules()->settings($moduleName);
$notice = null;
$error = null;
$getValue = static function (array $source, string $path): mixed {
$value = $source;
foreach (explode('.', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
};
$setValue = static function (array &$target, string $path, mixed $value): void {
$segments = explode('.', $path);
$cursor = &$target;
foreach ($segments as $index => $segment) {
if ($index === count($segments) - 1) {
$cursor[$segment] = $value;
return;
}
if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
$cursor[$segment] = [];
}
$cursor = &$cursor[$segment];
}
};
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') {
try {
$nextSettings = $settings;
foreach ($fields as $field) {
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$type = trim((string) ($field['type'] ?? 'text'));
if ($name === '') {
continue;
}
$raw = $_POST['settings'][$name] ?? null;
$value = match ($type) {
'checkbox' => $raw !== null,
'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null),
default => is_string($raw) ? trim($raw) : '',
};
$setValue($nextSettings, $name, $value);
}
modules()->saveSettings($moduleName, $nextSettings);
$settings = modules()->settings($moduleName);
$notice = 'Setup gespeichert.';
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Modul-Setup</h2>
<p>Die Felder entsprechen dem alten Nexus-Modul und werden lokal in `data/module-settings/boersenchecker.json` gespeichert.</p>
</div>
</div>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_setup">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:12px;">
<?php foreach ($fields as $field): ?>
<?php
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$label = trim((string) ($field['label'] ?? $name));
$type = trim((string) ($field['type'] ?? 'text'));
$help = trim((string) ($field['help'] ?? ''));
$value = $getValue($settings, $name);
?>
<label class="setup-field muted">
<span><?= e($label) ?></span>
<?php if ($type === 'checkbox'): ?>
<input type="checkbox" name="settings[<?= e($name) ?>]" value="1" <?= !empty($value) ? 'checked' : '' ?>>
<?php elseif ($type === 'password'): ?>
<input type="password" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php elseif ($type === 'number'): ?>
<input type="number" step="any" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php else: ?>
<input type="text" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php endif; ?>
<?php if ($help !== ''): ?>
<small class="bc-text"><?= e($help) ?></small>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Setup speichern</button>
</div>
</form>
</section>
</div>
<?php
};
$renderContent = static function () use ($view, $renderSetup, $viewMeta): void {
$currentViewMeta = $viewMeta[$view] ?? $viewMeta['overview'];
$navItems = [
'overview' => '/apps/boersenchecker/index.php?view=overview',
'depotverwaltung' => '/apps/boersenchecker/index.php?view=depotverwaltung',
'aktienverwaltung' => '/apps/boersenchecker/index.php?view=aktienverwaltung',
'setup' => '/apps/boersenchecker/index.php?view=setup',
];
ob_start();
if ($view === 'setup') {
$renderSetup();
} elseif ($view === 'depotverwaltung') {
$page = new \Modules\Boersenchecker\Support\DashboardPage();
module_tpl('boersenchecker', 'dashboard', $page->handle());
} elseif ($view === 'aktienverwaltung') {
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
module_tpl('boersenchecker', 'instruments', $page->handle());
} else {
$page = new \Modules\Boersenchecker\Support\HomePage();
module_tpl('boersenchecker', 'home', $page->handle());
}
$innerContent = (string) ob_get_clean();
?>
<div class="boersenchecker-module-shell window-app-shell">
<div class="window-app-frame bc-frame">
<aside class="window-app-sidebar bc-sidebar">
<div class="window-app-brand bc-brand">
<p class="window-app-kicker bc-kicker">Modul</p>
<h1>Boersenchecker</h1>
<p class="window-app-copy">Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung im Desktop-Standardlayout.</p>
</div>
<div class="window-app-nav-list bc-nav-list">
<?php foreach ($navItems as $navView => $href): ?>
<?php $meta = $viewMeta[$navView] ?? null; ?>
<?php if (!$meta): continue; endif; ?>
<a
class="window-app-nav-button bc-nav-button<?= $view === $navView ? ' is-active' : '' ?>"
href="<?= e($href) ?>"
>
<strong><?= e((string) $meta['label']) ?></strong>
<span class="window-app-meta"><?= e((string) $meta['nav_description']) ?></span>
</a>
<?php endforeach; ?>
</div>
</aside>
<main class="window-app-main bc-main">
<div class="window-app-panel bc-panel-shell">
<section class="window-app-hero bc-hero">
<div>
<p class="window-app-kicker bc-kicker">Boersenchecker</p>
<h2 class="window-app-title bc-title"><?= e((string) $currentViewMeta['title']) ?></h2>
<p class="window-app-copy"><?= e((string) $currentViewMeta['description']) ?></p>
</div>
<div class="window-app-pill-row bc-pill-row">
<span class="window-app-pill bc-pill">Desktop-App</span>
<span class="window-app-pill bc-pill">Legacy-Migration</span>
<span class="window-app-pill bc-pill">View: <?= e((string) $currentViewMeta['label']) ?></span>
</div>
</section>
<?= $innerContent ?>
</div>
</main>
</div>
</div>
<?php
};
ob_start();
$renderContent();
$pageContent = (string) ob_get_clean();
if ($isPartial) {
echo $pageContent;
return;
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Börsenchecker</title>
<style>
:root {
color-scheme: light;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #475569;
--brand-accent: #14b8a6;
--brand-accent-2: #0f766e;
--brand-accent-3: #0f766e;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: var(--text);
}
.boersenchecker-module-shell {
min-height: 100vh;
padding: 18px;
}
.section-box,
.submenu-box,
.card-box {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
padding: 22px 24px;
}
.module-page-stack {
display: grid;
gap: 18px;
}
.module-hero-copy { margin-bottom: 16px; }
.module-title { margin: 0; font-size: 2.4rem; line-height: 1.05; }
.module-lead { margin: 12px 0 0; color: var(--muted); line-height: 1.6; }
.eyebrow {
margin: 0 0 12px;
color: #4338ca;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 12px;
font-weight: 700;
}
.module-hero-top--compact,
.module-tabs,
.module-hero-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
.module-hero-top--compact { justify-content:space-between; gap:16px; }
.module-button {
display:inline-flex;
align-items:center;
justify-content:center;
min-height:44px;
padding:10px 16px;
border-radius:999px;
text-decoration:none;
border:1px solid rgba(148, 163, 184, 0.28);
color:#0f172a;
background:#ffffff;
font-weight:700;
}
.module-button--tab-active,
.module-button--primary {
color:#ffffff;
border-color:transparent;
background:linear-gradient(135deg, var(--brand-accent), var(--brand-accent-3));
}
.module-button--secondary,
.module-button--tab {
background: rgba(255, 255, 255, 0.94);
}
.module-button--small { min-height:40px; padding:8px 14px; }
.setup-field { display:grid; gap:8px; }
.setup-field input, .setup-field select, .setup-field textarea {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 14px;
font: inherit;
background: #ffffff;
color: #0f172a;
}
.setup-field textarea { min-height: 100px; }
.grid { display:grid; }
</style>
<link rel="stylesheet" href="/assets/desktop/desktop.css">
<link rel="stylesheet" href="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.css&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.css'), ENT_QUOTES) ?>">
</head>
<body>
<?= $pageContent ?>
<script src="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.js&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,509 @@
<?php $ownerQuery = $isAdmin ? '&owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<?php if ($isAdmin): ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Benutzer-Scope</h2>
<p>Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.</p>
</div>
</div>
<form method="get" action="/apps/boersenchecker/index.php" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="view" value="depotverwaltung">
<label class="setup-field muted" style="margin:0; min-width:260px;">
<span>Depots von Benutzer</span>
<select name="owner_sub">
<?php foreach ($availableOwners as $owner): ?>
<option value="<?= e((string) $owner['sub']) ?>" <?= (string) $ownerSub === (string) $owner['sub'] ? 'selected' : '' ?>>
<?= e((string) $owner['label']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button class="bc-button bc-button--primary" type="submit">Anzeigen</button>
</form>
</section>
<?php endif; ?>
<div class="bc-card-grid">
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title"><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></h2>
<p>Stammdaten und Berichtswahrung fuer ein Depot pflegen.</p>
</div>
</div>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_portfolio">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depotname</span>
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Berichtswahrung</span>
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
</label>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Depot speichern</button>
<?php if ($editPortfolio): ?>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
<?php endif; ?>
</div>
</form>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">API / FX</h2>
<p>Kurs- und Waehrungsdaten zentral aktualisieren.</p>
</div>
</div>
<p class="muted" style="margin-top:16px;">
Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell
angestossen und respektiert die dortige Max-Age- und Reuse-Logik.
</p>
<p class="muted" style="margin-top:12px;">
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.
</p>
<div class="bc-actions" style="margin-top:16px;">
<form method="post">
<input type="hidden" name="action" value="refresh_fx">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<button class="bc-button bc-button--primary" type="submit">FX-Daten aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="action" value="refresh_market_data_all">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<button class="bc-button bc-button--secondary" type="submit">Alle API-Kurse abrufen</button>
</form>
</div>
<div class="muted" style="margin-top:12px;">
Alpha Vantage Mindestabstand: <?= e((string) $marketDataMinIntervalMinutes) ?> Min.
</div>
<div class="muted" style="margin-top:6px;">
API-Key und Timeout fuer Aktienkurse werden ueber <a href="/apps/boersenchecker/index.php?view=setup">dieses Modul-Setup</a> gepflegt.
</div>
<div class="muted" style="margin-top:6px;">
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/apps/fx-rates">fx-rates</a> gepflegt.
</div>
<div class="muted" style="margin-top:12px;">
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
</div>
</section>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title"><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></h2>
<p>Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.</p>
</div>
</div>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:16px;">Bitte zuerst ein Depot anlegen.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depot</span>
<select name="portfolio_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktienname</span>
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>API-Symbol / Ticker</span>
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
</label>
<label class="setup-field muted">
<span>ISIN</span>
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>WKN</span>
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Boerse / Markt</span>
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Kurswaehrung</span>
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Stueckzahl</span>
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufpreis</span>
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufwaehrung</span>
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufdatum</span>
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
</label>
<label class="setup-field muted">
<span>Gebuehren</span>
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
</label>
</div>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
</label>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Position speichern</button>
<?php if ($editPosition): ?>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
<?php endif; ?>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Wertpapiersuche</h2>
<p>Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.</p>
</div>
</div>
<div class="bc-section-copy">
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="action" value="search_symbol">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
<span>Suchbegriff</span>
<input type="text" name="search_keywords" value="<?= e($symbolSearchKeywords) ?>" placeholder="z.B. Mercedes, AAPL, Allianz" required>
</label>
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
</form>
</div>
<?php if ($symbolSearchResults !== []): ?>
<table class="bc-table">
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>Typ</th>
<th>Region</th>
<th>Waehrung</th>
<th>Match</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($symbolSearchResults as $result): ?>
<tr>
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
<td><?= e((string) ($result['name'] ?? '')) ?></td>
<td><?= e((string) ($result['type'] ?? '')) ?></td>
<td><?= e((string) ($result['region'] ?? '')) ?></td>
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
<td><?= e((string) ($result['match_score'] ?? '')) ?></td>
<td>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>&quote_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
In Formular uebernehmen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
</div>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Manuellen Kurs erfassen</h2>
<p>Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.</p>
</div>
</div>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:16px;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_quote">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktie</span>
<select name="quote_instrument_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($instrumentList as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field muted">
<span>Kurs</span>
<input type="number" name="quote_price" min="0" step="0.00000001" required>
</label>
<label class="setup-field muted">
<span>Waehrung</span>
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
</label>
<label class="setup-field muted">
<span>Zeitpunkt</span>
<input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required>
</label>
<label class="setup-field muted">
<span>Quelle</span>
<input type="text" name="quote_source" value="manual">
</label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Depots</h2>
<p>Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.</p>
</div>
</div>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:16px;">Noch keine Depots vorhanden.</div>
<?php else: ?>
<div class="bc-card-grid" style="margin-top:16px;">
<?php foreach ($portfolios as $portfolio): ?>
<?php
$portfolioId = (int) $portfolio['id'];
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
?>
<section class="card-box">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $portfolio['name']) ?></strong>
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
</div>
<div class="bc-actions">
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
<input type="hidden" name="action" value="delete_portfolio">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</div>
</div>
<?php if (!empty($portfolio['notes'])): ?>
<div class="muted" style="margin-top:10px;"><?= e((string) $portfolio['notes']) ?></div>
<?php endif; ?>
<div class="grid" style="margin-top:16px; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
<div class="bc-stat">
<div class="muted">Investiert</div>
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="bc-stat">
<div class="muted">Aktuell</div>
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="bc-stat">
<div class="muted">Gewinn / Verlust</div>
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
</div>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Positionen</h2>
<p>Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.</p>
</div>
</div>
<?php if ($positions === []): ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Positionen vorhanden.</div>
</div>
<?php else: ?>
<table class="bc-table">
<thead>
<tr>
<th>Depot</th>
<th>Aktie</th>
<th>ISIN / WKN</th>
<th>Stueck</th>
<th>Kauf</th>
<th>Letzter Kurs</th>
<th>Wert</th>
<th>Delta</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($positions as $position): ?>
<tr>
<td><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
<td>
<strong><?= e((string) $position['instrument_name']) ?></strong>
<?php if (!empty($position['symbol'])): ?>
<div class="muted"><?= e((string) $position['symbol']) ?></div>
<?php endif; ?>
</td>
<td>
<?= e((string) ($position['isin'] ?: '-')) ?>
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
</td>
<td><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
<td>
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
</td>
<td>
<?php if ($position['latest_price'] !== null): ?>
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
<div class="muted"><?= e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?></div>
<?php else: ?>
<span class="muted">kein Kurs</span>
<?php endif; ?>
</td>
<td>
<?php if ($position['current_total_base'] !== null): ?>
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td>
<?php if ($position['gain_base'] !== null): ?>
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td>
<div class="bc-actions">
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
<form method="post">
<input type="hidden" name="action" value="refresh_market_data_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">API-Kurs</button>
</form>
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
<input type="hidden" name="action" value="delete_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Historische Kurse pro Aktie mit Zeitstempel und Quelle.</p>
</div>
</div>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:16px;">Noch keine Kursdaten vorhanden.</div>
<?php else: ?>
<div class="bc-card-grid" style="margin-top:16px;">
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
<section class="card-box">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $instrument['name']) ?></strong>
<div class="muted">
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
</div>
</div>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
</div>
<?php if ($history === []): ?>
<div class="muted" style="margin-top:12px;">Noch keine historischen Kurse vorhanden.</div>
<?php else: ?>
<div class="bc-table-shell" style="margin-top:12px;">
<table class="bc-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Kurs</th>
<th>Quelle</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($history as $quote): ?>
<tr>
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
<td><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
<td><?= e((string) $quote['source']) ?></td>
<td>
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>

View File

@@ -0,0 +1,207 @@
<div class="bc-page" data-bc-home data-chart-endpoint="/api/boersenchecker/index.php?path=v1/chart-data">
<script type="application/json" data-bc-instruments-json><?= json_encode(array_map(static function (array $position): array {
return [
'instrument_id' => (int) ($position['instrument_id'] ?? 0),
'instrument_name' => (string) ($position['instrument_name'] ?? ''),
'symbol' => (string) ($position['symbol'] ?? ''),
'isin' => (string) ($position['isin'] ?? ''),
];
}, $positions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Marktueberblick</h2>
<p>Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.</p>
</div>
</div>
<div class="bc-toolbar" style="margin-top:16px;">
<form class="bc-panel" method="get" action="/apps/boersenchecker/index.php">
<input type="hidden" name="view" value="overview">
<div class="bc-field-label">Depotauswahl</div>
<?php if ($portfolios === []): ?>
<div class="bc-text" style="margin-top:12px;">Keine Depots vorhanden.</div>
<?php else: ?>
<label class="setup-field" style="margin-top:12px;">
<span class="bc-text">Depot</span>
<select name="portfolio_id" onchange="this.form.submit()">
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) $selectedPortfolioId === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
</form>
<form class="bc-panel" method="get" action="/apps/boersenchecker/index.php">
<input type="hidden" name="view" value="overview">
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
<div class="bc-field-label">Aktienauswahl</div>
<?php if ($positions === []): ?>
<div class="bc-text" style="margin-top:12px;">Keine Aktien im ausgewaehlten Depot.</div>
<?php else: ?>
<label class="setup-field" style="margin-top:12px;">
<span class="bc-text">Aktie</span>
<select name="instrument_id" data-bc-instrument onchange="this.form.submit()">
<?php foreach ($positions as $position): ?>
<option value="<?= e((string) $position['instrument_id']) ?>" <?= (string) $selectedInstrumentId === (string) $position['instrument_id'] ? 'selected' : '' ?>>
<?= e((string) $position['instrument_name']) ?><?= !empty($position['symbol']) ? ' (' . e((string) $position['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
</form>
<form class="bc-panel" method="post">
<input type="hidden" name="action" value="refresh_current_quotes_home">
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
<div class="bc-field-label">Marktdaten</div>
<p class="bc-text" style="margin-top:12px;">Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.</p>
<div class="bc-actions" style="margin-top:16px;">
<button class="bc-button bc-button--primary" type="submit" <?= $selectedPortfolioId > 0 ? '' : 'disabled' ?>>Aktuelle Kurse abrufen</button>
</div>
</form>
</div>
</section>
<div class="bc-overview-grid">
<section class="card-box bc-stat">
<div class="bc-field-label">Positionen</div>
<div class="bc-stat-value"><?= e((string) ($summary['positions'] ?? 0)) ?></div>
<div class="bc-text" style="margin-top:6px;">Aktien im aktuell gewaehlten Depot</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Investiert</div>
<div class="bc-stat-value"><?= isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;">In Berichtswahrung bewertet</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Aktueller Wert</div>
<div class="bc-stat-value"><?= isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;">Basierend auf dem letzten gespeicherten Kurs</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Performance</div>
<div class="bc-stat-value"><?= isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;"><?= !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?></div>
</section>
</div>
<div class="bc-card-grid">
<section class="card-box">
<div class="bc-field-label">Bester Wert</div>
<?php if (!empty($summary['best'])): ?>
<div class="bc-stat-value"><?= e((string) $summary['best']['instrument_name']) ?></div>
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
<?php else: ?>
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
<?php endif; ?>
</section>
<section class="card-box">
<div class="bc-field-label">Schwaechster Wert</div>
<?php if (!empty($summary['worst'])): ?>
<div class="bc-stat-value"><?= e((string) $summary['worst']['instrument_name']) ?></div>
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
<?php else: ?>
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
<?php endif; ?>
</section>
<?php foreach (array_slice($positions, 0, 2) as $position): ?>
<section class="card-box bc-stat">
<div class="bc-field-label"><?= e((string) $position['instrument_name']) ?></div>
<div class="bc-stat-value"><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;"><?= e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?></div>
</section>
<?php endforeach; ?>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.</p>
</div>
</div>
<div class="bc-chart-card" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
<div>
<div class="bc-field-label">Aktie</div>
<div class="bc-stat-value" data-bc-instrument-name><?= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?></div>
<?php if ($selectedInstrument): ?>
<div class="bc-text" data-bc-instrument-meta><?= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · <?= e((string) ($selectedInstrument['isin'] ?? '-')) ?></div>
<?php endif; ?>
</div>
<div class="bc-range-list">
<button type="button" class="bc-range-button" data-range="1d" aria-pressed="false">Tag</button>
<button type="button" class="bc-range-button" data-range="5d" aria-pressed="false">5 Tage</button>
<button type="button" class="bc-range-button" data-range="1m" aria-pressed="true">Monat</button>
<button type="button" class="bc-range-button" data-range="3m" aria-pressed="false">3 Monate</button>
<button type="button" class="bc-range-button" data-range="6m" aria-pressed="false">6 Monate</button>
<button type="button" class="bc-range-button" data-range="1y" aria-pressed="false">Jahr</button>
<button type="button" class="bc-range-button" data-range="5y" aria-pressed="false">5 Jahre</button>
</div>
</div>
<div class="bc-text" data-bc-chart-status style="margin-top:12px;">Chartdaten werden geladen...</div>
<div class="bc-stat-value" data-bc-chart-summary style="margin-top:6px;">-</div>
<div class="bc-chart-shell" data-bc-chart style="margin-top:18px;"></div>
</div>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktien im Depot</h2>
<p>Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.</p>
</div>
</div>
<div class="bc-section-copy">
<?php if ($positions === []): ?>
<div class="bc-text" style="padding:0 0 18px;">Keine Aktien im ausgewaehlten Depot.</div>
<?php else: ?>
<div class="bc-position-list" style="padding:0 0 18px;">
<?php foreach ($positions as $position): ?>
<?php $gainClass = (($position['gain_report'] ?? 0) >= 0) ? 'is-positive' : 'is-negative'; ?>
<div class="bc-position-row">
<div>
<strong><?= e((string) $position['instrument_name']) ?></strong>
<div class="bc-text" style="margin-top:4px;"><?= e((string) ($position['symbol'] ?? '')) ?> · <?= e((string) ($position['isin'] ?? '-')) ?></div>
<?php if (!empty($position['market'])): ?>
<div class="bc-pill-soft" style="margin-top:10px;"><?= e((string) $position['market']) ?></div>
<?php endif; ?>
</div>
<div>
<div class="bc-field-label">Stueckzahl</div>
<div><?= e(number_format((float) $position['quantity'], 6, ',', '.')) ?></div>
</div>
<div>
<div class="bc-field-label">Kaufpreis</div>
<div><?= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> <?= e((string) $position['purchase_currency']) ?></div>
</div>
<div>
<div class="bc-field-label">Letzter Kurs</div>
<div><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
</div>
<div>
<div class="bc-field-label">Performance</div>
<div class="bc-performance <?= e($gainClass) ?>">
<?= isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
</div>

View File

@@ -0,0 +1,184 @@
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<div class="bc-card-grid">
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktie waehlen</h2>
<p>Systemweit vorhandene Aktie aus allen Depots auswaehlen.</p>
</div>
</div>
<form method="get" action="/apps/boersenchecker/index.php" style="margin-top:16px;">
<input type="hidden" name="view" value="aktienverwaltung">
<label class="setup-field muted">
<span>Aktien aller Depots</span>
<select name="instrument_id" onchange="this.form.submit()">
<?php foreach ($instruments as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= (string) $selectedInstrumentId === (string) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Wertpapiersuche</h2>
<p>Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.</p>
</div>
</div>
<div class="bc-section-copy">
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="action" value="search_symbol">
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
<span>Suchbegriff</span>
<input type="text" name="search_keywords" value="<?= e($searchKeywords) ?>" placeholder="z.B. Apple, AAPL, Allianz" required>
</label>
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
</form>
</div>
<?php if ($searchResults !== []): ?>
<table class="bc-table">
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>Region</th>
<th>Waehrung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($searchResults as $result): ?>
<tr>
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
<td><?= e((string) ($result['name'] ?? '')) ?></td>
<td><?= e((string) ($result['region'] ?? '')) ?></td>
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
<td>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker/index.php?view=aktienverwaltung&instrument_id=<?= e((string) $selectedInstrumentId) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>&quote_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
Uebernehmen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
</div>
<?php endif; ?>
</section>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktie bearbeiten</h2>
<p>Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.</p>
</div>
</div>
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_instrument">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted"><span>Name</span><input type="text" name="instrument_name" value="<?= e((string) (($selectedInstrument['name'] ?? '') ?: ($_GET['instrument_name_candidate'] ?? ''))) ?>" required></label>
<label class="setup-field muted"><span>Symbol</span><input type="text" name="symbol" value="<?= e((string) (($selectedInstrument['symbol'] ?? '') ?: ($_GET['symbol_candidate'] ?? ''))) ?>"></label>
<label class="setup-field muted"><span>ISIN</span><input type="text" name="isin" value="<?= e((string) $selectedInstrument['isin'] ?? '') ?>"></label>
<label class="setup-field muted"><span>WKN</span><input type="text" name="wkn" value="<?= e((string) $selectedInstrument['wkn'] ?? '') ?>"></label>
<label class="setup-field muted"><span>Markt</span><input type="text" name="market" value="<?= e((string) (($selectedInstrument['market'] ?? '') ?: ($_GET['market_candidate'] ?? ''))) ?>"></label>
<label class="setup-field muted"><span>Kurswaehrung</span><input type="text" name="quote_currency" value="<?= e((string) (($selectedInstrument['quote_currency'] ?? $defaultReportCurrency) ?: ($_GET['quote_currency_candidate'] ?? $defaultReportCurrency))) ?>"></label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Aktie speichern</button>
</div>
</form>
<form method="post" style="margin-top:12px;">
<input type="hidden" name="action" value="refresh_market_data_instrument">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<button class="bc-button bc-button--secondary" type="submit">Aktuellen API-Kurs abrufen</button>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Manuellen Kurs eingeben</h2>
<p>Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.</p>
</div>
</div>
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_quote">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted"><span>Kurs</span><input type="number" name="quote_price" min="0" step="0.00000001" required></label>
<label class="setup-field muted"><span>Waehrung</span><input type="text" name="quote_currency" value="<?= e((string) ($selectedInstrument['quote_currency'] ?? $defaultReportCurrency)) ?>" required></label>
<label class="setup-field muted"><span>Zeitpunkt</span><input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required></label>
<label class="setup-field muted"><span>Quelle</span><input type="text" name="quote_source" value="manual"></label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.</p>
</div>
</div>
<?php if ($quotes === []): ?>
<div class="bc-section-copy">
<div class="muted">Keine Kursdaten vorhanden.</div>
</div>
<?php else: ?>
<table class="bc-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Kurs</th>
<th>Quelle</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($quotes as $quote): ?>
<tr>
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
<td><?= e(number_format((float) $quote['price'], 4, ',', '.')) ?> <?= e((string) $quote['currency']) ?></td>
<td><?= e((string) $quote['source']) ?></td>
<td>
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
</div>

View File

@@ -0,0 +1,898 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class DashboardPage
{
private PDO $pdo;
private array $user;
private bool $isAdmin;
private string $ownerSub;
private array $moduleSettings;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
private int $editPortfolioId;
private int $editPositionId;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private InstrumentRegistry $instrumentRegistry;
private string $symbolSearchKeywords = '';
private array $symbolSearchResults = [];
private array $availableOwners = [];
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$this->user = \auth_user() ?? [];
$this->isAdmin = \auth_is_admin();
$this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
$this->availableOwners = $this->buildAvailableOwners();
if ($this->isAdmin) {
$requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
$this->ownerSub = $requestedOwner;
}
}
$this->moduleSettings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR'));
$this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
if ($this->marketDataMinIntervalMinutes <= 0) {
$this->marketDataMinIntervalMinutes = 60;
}
$this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0);
$this->editPositionId = (int) ($_GET['edit_position'] ?? 0);
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->portfolioTable = $table('portfolios');
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
$this->instrumentRegistry = new InstrumentRegistry(
$this->pdo,
$this->instrumentTable,
$this->positionTable,
$this->quoteTable,
);
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$notice = $this->handlePost();
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$state = $this->loadState();
if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) {
$notice = $state['notice_override'];
}
if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) {
$error = $state['error_override'];
}
return [
'notice' => $notice,
'error' => $error,
'isAdmin' => $this->isAdmin,
'ownerSub' => $this->ownerSub,
'availableOwners' => array_values($this->availableOwners),
'defaultReportCurrency' => $this->defaultReportCurrency,
'fxMaxAgeHours' => $this->fxMaxAgeHours,
'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes,
'symbolSearchKeywords' => $this->symbolSearchKeywords,
'symbolSearchResults' => $this->symbolSearchResults,
'editPortfolio' => $state['editPortfolio'],
'editPosition' => $state['editPosition'],
'portfolios' => $state['portfolios'],
'portfolioById' => $state['portfolioById'],
'portfolioStats' => $state['portfolioStats'],
'positions' => $state['positions'],
'instrumentList' => $state['instrumentList'],
'quoteHistory' => $state['quoteHistory'],
'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'],
'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'],
'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale),
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
];
}
private function handlePost(): string
{
$action = trim((string) ($_POST['action'] ?? ''));
return match ($action) {
'save_portfolio' => $this->savePortfolio(),
'delete_portfolio' => $this->deletePortfolio(),
'save_position' => $this->savePosition(),
'delete_position' => $this->deletePosition(),
'save_quote' => $this->saveQuote(),
'refresh_market_data_position' => $this->refreshMarketDataPosition(),
'refresh_market_data_all' => $this->refreshMarketDataAll(),
'search_symbol' => $this->searchSymbol(),
'delete_quote' => $this->deleteQuote(),
'refresh_fx' => $this->refreshFx(),
default => '',
};
}
private function loadState(): array
{
$portfolios = $this->fetchPortfolios();
$positions = $this->fetchPositions();
$instrumentList = $this->buildInstrumentList($positions);
[$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList));
$portfolioById = $this->buildPortfolioById($portfolios);
[$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes);
$editPortfolio = null;
if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) {
$editPortfolio = $portfolioById[$this->editPortfolioId];
}
$editPosition = null;
if ($this->editPositionId > 0) {
foreach ($positions as $position) {
if ((int) $position['id'] === $this->editPositionId) {
$editPosition = $position;
break;
}
}
}
$selectedInstrumentForQuote = $editPosition
? (int) $editPosition['instrument_id']
: (int) ($_GET['instrument_id'] ?? 0);
$selectedInstrumentQuoteCurrency = $this->defaultReportCurrency;
if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) {
$selectedInstrumentQuoteCurrency = $this->normalizeCurrency(
(string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency)
);
}
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
$candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency));
if ($editPosition === null) {
$editPosition = [
'instrument_name' => $candidateName,
'symbol' => $candidateSymbol,
'isin' => $candidateIsin,
'market' => $candidateMarket,
'quote_currency' => $candidateCurrency,
'purchase_currency' => $this->defaultReportCurrency,
'purchase_date' => date('Y-m-d'),
];
} else {
if ($candidateName !== '') {
$editPosition['instrument_name'] = $candidateName;
}
if ($candidateSymbol !== '') {
$editPosition['symbol'] = $candidateSymbol;
}
if ($candidateIsin !== '') {
$editPosition['isin'] = $candidateIsin;
}
if ($candidateMarket !== '') {
$editPosition['market'] = $candidateMarket;
}
if ($candidateCurrency !== '') {
$editPosition['quote_currency'] = $candidateCurrency;
}
}
return [
'portfolios' => $portfolios,
'portfolioById' => $portfolioById,
'portfolioStats' => $portfolioStats,
'positions' => $positions,
'instrumentList' => $instrumentList,
'quoteHistory' => $quoteHistory,
'editPortfolio' => $editPortfolio,
'editPosition' => $editPosition,
'selectedInstrumentForQuote' => $selectedInstrumentForQuote,
'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency,
];
}
private function savePortfolio(): string
{
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$name = trim((string) ($_POST['portfolio_name'] ?? ''));
$baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency));
$notes = trim((string) ($_POST['portfolio_notes'] ?? ''));
if ($name === '') {
throw new RuntimeException('Bitte einen Depotnamen angeben.');
}
if ($portfolioId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->portfolioTable . '
SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND owner_sub = :owner_sub'
);
$stmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
'name' => $name,
'base_currency' => $baseCurrency,
'notes' => $notes !== '' ? $notes : null,
]);
return 'Depot aktualisiert.';
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes)
VALUES (:owner_sub, :name, :base_currency, :notes)'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'name' => $name,
'base_currency' => $baseCurrency,
'notes' => $notes !== '' ? $notes : null,
]);
return 'Depot angelegt.';
}
private function deletePortfolio(): string
{
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub');
$countStmt->execute([
'portfolio_id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
if ((int) $countStmt->fetchColumn() > 0) {
throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.');
}
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub');
$stmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
return 'Depot geloescht.';
}
private function savePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$quantity = (float) ($_POST['quantity'] ?? 0);
$purchasePrice = (float) ($_POST['purchase_price'] ?? 0);
$purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency));
$purchaseDate = trim((string) ($_POST['purchase_date'] ?? ''));
$fees = trim((string) ($_POST['fees'] ?? ''));
$notes = trim((string) ($_POST['position_notes'] ?? ''));
if ($portfolioId <= 0) {
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
}
if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') {
throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.');
}
$portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
$portfolioOwnerStmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
if ($portfolioOwnerStmt->fetchColumn() === false) {
throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.');
}
$instrumentId = $this->upsertInstrument([
'id' => (int) ($_POST['instrument_id'] ?? 0),
'isin' => $_POST['isin'] ?? '',
'wkn' => $_POST['wkn'] ?? '',
'symbol' => $_POST['symbol'] ?? '',
'name' => $_POST['instrument_name'] ?? '',
'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency,
'market' => $_POST['market'] ?? '',
]);
$payload = [
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
'instrument_id' => $instrumentId,
'quantity' => $quantity,
'purchase_price' => $purchasePrice,
'purchase_currency' => $purchaseCurrency,
'purchase_date' => $purchaseDate,
'fees' => $fees !== '' ? (float) $fees : null,
'notes' => $notes !== '' ? $notes : null,
];
if ($positionId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->positionTable . '
SET portfolio_id = :portfolio_id,
instrument_id = :instrument_id,
quantity = :quantity,
purchase_price = :purchase_price,
purchase_currency = :purchase_currency,
purchase_date = :purchase_date,
fees = :fees,
notes = :notes,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND owner_sub = :owner_sub'
);
$stmt->execute($payload + ['id' => $positionId]);
return 'Position aktualisiert.';
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->positionTable . ' (
owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes
) VALUES (
:owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes
)'
);
$stmt->execute($payload);
return 'Position gespeichert.';
}
private function deletePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub');
$stmt->execute([
'id' => $positionId,
'owner_sub' => $this->ownerSub,
]);
return 'Position geloescht.';
}
private function saveQuote(): string
{
$instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0);
$price = (float) ($_POST['quote_price'] ?? 0);
$currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency));
$quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? ''));
$source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual';
if ($instrumentId <= 0 || $price <= 0) {
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
}
$this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source);
return 'Kurs gespeichert.';
}
private function refreshMarketDataPosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$stmt = $this->pdo->prepare(
'SELECT
p.instrument_id,
i.name AS instrument_name,
i.symbol,
i.isin,
i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $positionId,
'owner_sub' => $this->ownerSub,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
throw new RuntimeException('Position nicht gefunden.');
}
$instrumentId = (int) $row['instrument_id'];
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
if ($symbol === '') {
throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.');
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
}
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
if (empty($apiResult['ok'])) {
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
}
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
$displayTime = (string) \module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($apiResult['fetched_at'] ?? ''),
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
);
return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.';
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.';
}
return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
}
private function refreshMarketDataAll(): string
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT
i.id,
i.name,
i.symbol,
i.isin,
i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub
ORDER BY i.name ASC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.');
}
$fetched = 0;
$reused = 0;
$stale = 0;
$skipped = 0;
$failed = 0;
$errors = [];
$bulkRows = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
if ($instrumentId <= 0 || $symbol === '') {
$skipped++;
continue;
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
$reused++;
continue;
}
$bulkRows[] = $row;
}
if ($bulkRows !== []) {
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
$bulkRows
))));
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows);
if (empty($bulkResult['ok'])) {
throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.'));
}
$bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
foreach ($bulkRows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
$apiResult = $bulkQuotes[$instrumentId] ?? null;
if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) {
$failed++;
$errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.';
continue;
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
$stale++;
continue;
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
$fetched++;
} else {
$reused++;
}
}
}
if ($errors !== []) {
throw new RuntimeException(
'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. '
. implode(' | ', array_slice($errors, 0, 3))
);
}
return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.';
}
private function searchSymbol(): string
{
$keywords = trim((string) ($_POST['search_keywords'] ?? ''));
$this->symbolSearchKeywords = $keywords;
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords);
$this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
}
private function deleteQuote(): string
{
$quoteId = (int) ($_POST['quote_id'] ?? 0);
$stmt = $this->pdo->prepare(
'DELETE FROM ' . $this->quoteTable . '
WHERE id = :id
AND instrument_id IN (
SELECT DISTINCT instrument_id
FROM ' . $this->positionTable . '
WHERE owner_sub = :owner_sub
)'
);
$stmt->execute([
'id' => $quoteId,
'owner_sub' => $this->ownerSub,
]);
return 'Kurseintrag geloescht.';
}
private function refreshFx(): string
{
$result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours);
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'FX-Daten aktualisiert.');
}
private function fetchPortfolios(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchPositions(): array
{
$stmt = $this->pdo->prepare(
'SELECT
p.*,
i.isin,
i.wkn,
i.symbol,
i.name AS instrument_name,
i.quote_currency,
i.market
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub
ORDER BY p.purchase_date DESC, p.id DESC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function buildInstrumentList(array $positions): array
{
$instrumentList = [];
foreach ($positions as $position) {
$instrumentId = (int) $position['instrument_id'];
if (!isset($instrumentList[$instrumentId])) {
$instrumentList[$instrumentId] = [
'id' => $instrumentId,
'name' => (string) $position['instrument_name'],
'symbol' => (string) ($position['symbol'] ?? ''),
'isin' => (string) ($position['isin'] ?? ''),
'quote_currency' => (string) ($position['quote_currency'] ?? ''),
];
}
}
return $instrumentList;
}
private function fetchQuotes(array $instrumentIds): array
{
$quoteHistory = [];
$latestQuotes = [];
if ($instrumentIds === []) {
return [$quoteHistory, $latestQuotes];
}
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$stmt->execute($instrumentIds);
$quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($quotes as $quote) {
$instrumentId = (int) $quote['instrument_id'];
$quoteHistory[$instrumentId][] = $quote;
if (!isset($latestQuotes[$instrumentId])) {
$latestQuotes[$instrumentId] = $quote;
}
}
return [$quoteHistory, $latestQuotes];
}
private function buildPortfolioById(array $portfolios): array
{
$portfolioById = [];
foreach ($portfolios as $portfolio) {
$portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency));
$portfolioById[(int) $portfolio['id']] = $portfolio;
}
return $portfolioById;
}
private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array
{
$portfolioStats = [];
foreach ($portfolioById as $portfolioId => $portfolio) {
$portfolioStats[$portfolioId] = [
'invested' => 0.0,
'current' => 0.0,
'gain' => 0.0,
'positions' => 0,
'has_invested' => false,
'has_current' => false,
];
}
foreach ($positions as &$position) {
$portfolioId = (int) $position['portfolio_id'];
$baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency);
$quantity = (float) $position['quantity'];
$purchasePrice = (float) $position['purchase_price'];
$fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0;
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
$purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency);
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
$currentTotalBase = null;
if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) {
$currentOriginal = $quantity * (float) $latestQuote['price'];
$currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? ''));
$position['latest_price'] = (float) $latestQuote['price'];
$position['latest_currency'] = (string) $latestQuote['currency'];
$position['latest_quoted_at'] = (string) $latestQuote['quoted_at'];
$position['latest_source'] = (string) ($latestQuote['source'] ?? '');
$position['current_total_base'] = $currentTotalBase;
} else {
$position['latest_price'] = null;
$position['latest_currency'] = null;
$position['latest_quoted_at'] = null;
$position['latest_source'] = null;
$position['current_total_base'] = null;
}
$position['purchase_total'] = $purchaseTotal;
$position['purchase_total_base'] = $purchaseTotalBase;
$position['base_currency'] = $baseCurrency;
$position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null
? $currentTotalBase - $purchaseTotalBase
: null;
if (isset($portfolioStats[$portfolioId])) {
$portfolioStats[$portfolioId]['positions']++;
if ($purchaseTotalBase !== null) {
$portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase;
$portfolioStats[$portfolioId]['has_invested'] = true;
}
if ($currentTotalBase !== null) {
$portfolioStats[$portfolioId]['current'] += $currentTotalBase;
$portfolioStats[$portfolioId]['has_current'] = true;
}
}
}
unset($position);
foreach ($portfolioStats as &$stats) {
$stats['gain'] = ($stats['has_invested'] && $stats['has_current'])
? $stats['current'] - $stats['invested']
: null;
}
unset($stats);
return [$positions, $portfolioStats];
}
private function latestApiQuoteForInstrument(int $instrumentId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id
AND source LIKE :source
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'source' => 'alphavantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
{
if (!is_array($latestQuote)) {
return false;
}
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
if ($latestTimestamp === false || $incomingTimestamp === false) {
return false;
}
return $incomingTimestamp <= $latestTimestamp;
}
private function upsertInstrument(array $payload): int
{
return $this->instrumentRegistry->save($payload);
}
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
}
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
{
if ($amount === null) {
return null;
}
$from = $this->normalizeCurrency($from);
$to = $this->normalizeCurrency($to);
if ($from === $to) {
return $amount;
}
try {
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
}
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
{
$normalized = strtoupper(trim((string) $value));
return $normalized !== '' ? $normalized : $fallback;
}
private function normalizeDateTimeLocal(?string $value): string
{
$timezone = new \DateTimeZone(nexus_display_timezone_name());
$value = trim((string) $value);
if ($value === '') {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
if ($date instanceof \DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
try {
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
}
private function formatNumber(?float $value, int $scale = 2): string
{
if ($value === null) {
return 'n/a';
}
return number_format($value, $scale, ',', '.');
}
private function buildAvailableOwners(): array
{
$owners = [];
$currentSub = trim((string) ($this->user['sub'] ?? 'local'));
$owners[$currentSub] = [
'sub' => $currentSub,
'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
];
if (!$this->isAdmin) {
return $owners;
}
foreach (\modules()->knownAuthUsers() as $knownUser) {
$sub = trim((string) ($knownUser['sub'] ?? ''));
if ($sub === '') {
continue;
}
$label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
$owners[$sub] = [
'sub' => $sub,
'label' => $label !== '' ? $label : $sub,
];
}
uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
return $owners;
}
}

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class HomePage
{
private PDO $pdo;
private string $ownerSub;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$user = \auth_user() ?? [];
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
$settings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
if ($this->marketDataMinIntervalMinutes <= 0) {
$this->marketDataMinIntervalMinutes = 60;
}
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->portfolioTable = $table('portfolios');
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') {
try {
$notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0));
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$portfolios = $this->fetchPortfolios();
$selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0));
if ($selectedPortfolioId <= 0 && $portfolios !== []) {
$selectedPortfolioId = (int) $portfolios[0]['id'];
}
$positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : [];
$selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0);
if ($selectedInstrumentId <= 0 && $positions !== []) {
$selectedInstrumentId = (int) $positions[0]['instrument_id'];
}
$latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions))));
foreach ($positions as &$position) {
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
$position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null;
$position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : '';
$position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : '';
$position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : '';
$position['current_total_report'] = null;
$position['gain_report'] = null;
$position['gain_percent'] = null;
if ($position['latest_price'] !== null) {
$currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0);
$currentReport = $this->convertAmount(
$currentNative,
(string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)),
$this->defaultReportCurrency,
(string) ($position['latest_source'] ?? '')
);
$position['current_total_report'] = $currentReport;
if ($position['purchase_total_report'] !== null && $currentReport !== null) {
$gain = $currentReport - (float) $position['purchase_total_report'];
$position['gain_report'] = $gain;
$base = (float) $position['purchase_total_report'];
$position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null;
}
}
}
unset($position);
$selectedInstrument = null;
foreach ($positions as $position) {
if ((int) $position['instrument_id'] === $selectedInstrumentId) {
$selectedInstrument = $position;
break;
}
}
return [
'notice' => $notice,
'error' => $error,
'portfolios' => $portfolios,
'selectedPortfolioId' => $selectedPortfolioId,
'positions' => $positions,
'selectedInstrumentId' => $selectedInstrumentId,
'selectedInstrument' => $selectedInstrument,
'summary' => $this->buildSummary($positions),
'defaultReportCurrency' => $this->defaultReportCurrency,
'chartEndpoint' => '/api/boersenchecker/index.php?path=v1/chart-data',
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
];
}
private function refreshCurrentQuotesForPortfolio(int $portfolioId): string
{
if ($portfolioId <= 0) {
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
}
$stmt = $this->pdo->prepare(
'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.');
}
$updated = 0;
$reused = 0;
$stale = 0;
$bulkCandidates = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
if ($instrumentId <= 0 || $symbol === '') {
continue;
}
$latest = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
$reused++;
continue;
}
$bulkCandidates[] = $row;
}
if ($bulkCandidates !== []) {
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
$bulkCandidates
))));
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates);
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
foreach ($bulkCandidates as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$quote = $quotes[$instrumentId] ?? null;
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
continue;
}
$latest = $this->latestApiQuoteForInstrument($instrumentId);
if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) {
$stale++;
continue;
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $quote['price'],
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
$updated++;
} else {
$reused++;
}
}
}
return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.';
}
private function fetchPortfolios(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchPortfolioPositions(int $portfolioId): array
{
$stmt = $this->pdo->prepare(
'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id
ORDER BY i.name ASC'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($rows as &$row) {
$quantity = (float) ($row['quantity'] ?? 0);
$purchasePrice = (float) ($row['purchase_price'] ?? 0);
$fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0;
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
$row['purchase_total'] = $purchaseTotal;
$row['purchase_total_report'] = $this->convertAmount(
$purchaseTotal,
(string) ($row['purchase_currency'] ?? $this->defaultReportCurrency),
$this->defaultReportCurrency
);
}
unset($row);
return $rows;
}
private function fetchLatestQuotes(array $instrumentIds): array
{
$result = [];
if ($instrumentIds === []) {
return $result;
}
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$stmt->execute($instrumentIds);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$instrumentId = (int) $row['instrument_id'];
if (!isset($result[$instrumentId])) {
$result[$instrumentId] = $row;
}
}
return $result;
}
private function latestApiQuoteForInstrument(int $instrumentId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id AND source LIKE :source
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'source' => 'alphavantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
{
if (!is_array($latestQuote)) {
return false;
}
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
if ($latestTimestamp === false || $incomingTimestamp === false) {
return false;
}
return $incomingTimestamp <= $latestTimestamp;
}
private function buildSummary(array $positions): array
{
$invested = 0.0;
$current = 0.0;
$hasInvested = false;
$hasCurrent = false;
$best = null;
$worst = null;
foreach ($positions as $position) {
if (is_numeric($position['purchase_total_report'] ?? null)) {
$invested += (float) $position['purchase_total_report'];
$hasInvested = true;
}
if (is_numeric($position['current_total_report'] ?? null)) {
$current += (float) $position['current_total_report'];
$hasCurrent = true;
}
if (is_numeric($position['gain_percent'] ?? null)) {
if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) {
$best = $position;
}
if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) {
$worst = $position;
}
}
}
return [
'positions' => count($positions),
'invested' => $hasInvested ? $invested : null,
'current' => $hasCurrent ? $current : null,
'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null,
'best' => $best,
'worst' => $worst,
];
}
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
{
if ($amount === null) {
return null;
}
$from = $this->normalizeCurrency($from, $this->defaultReportCurrency);
$to = $this->normalizeCurrency($to, $this->defaultReportCurrency);
if ($from === $to) {
return $amount;
}
try {
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
}
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
{
$normalized = strtoupper(trim((string) $value));
return $normalized !== '' ? $normalized : $fallback;
}
}

View File

@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class InstrumentPage
{
private PDO $pdo;
private string $ownerSub;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private string $searchKeywords = '';
private array $searchResults = [];
private int $selectedInstrumentOverrideId = 0;
private InstrumentRegistry $instrumentRegistry;
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$user = \auth_user() ?? [];
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
$settings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
$this->instrumentRegistry = new InstrumentRegistry(
$this->pdo,
$this->instrumentTable,
$this->positionTable,
$this->quoteTable,
);
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$notice = $this->handlePost();
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$instruments = $this->fetchInstruments();
$selectedInstrumentId = $this->selectedInstrumentOverrideId > 0
? $this->selectedInstrumentOverrideId
: (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0));
if ($selectedInstrumentId <= 0 && $instruments !== []) {
$selectedInstrumentId = (int) $instruments[0]['id'];
}
$selectedInstrument = null;
foreach ($instruments as $instrument) {
if ((int) $instrument['id'] === $selectedInstrumentId) {
$selectedInstrument = $instrument;
break;
}
}
$quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : [];
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
$candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) {
$selectedInstrument = [
'id' => 0,
'name' => $candidateName,
'symbol' => $candidateSymbol,
'isin' => $candidateIsin,
'market' => $candidateMarket,
'quote_currency' => $candidateCurrency,
'wkn' => '',
];
}
return [
'notice' => $notice,
'error' => $error,
'instruments' => $instruments,
'selectedInstrument' => $selectedInstrument,
'selectedInstrumentId' => $selectedInstrumentId,
'quotes' => $quotes,
'searchKeywords' => $this->searchKeywords,
'searchResults' => $this->searchResults,
'defaultReportCurrency' => $this->defaultReportCurrency,
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
];
}
private function handlePost(): string
{
$action = trim((string) ($_POST['action'] ?? ''));
return match ($action) {
'save_instrument' => $this->saveInstrument(),
'save_quote' => $this->saveQuote(),
'delete_quote' => $this->deleteQuote(),
'refresh_market_data_instrument' => $this->refreshInstrumentQuote(),
'search_symbol' => $this->searchSymbol(),
default => '',
};
}
private function fetchInstruments(): array
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT i.*
FROM ' . $this->instrumentTable . ' i
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
WHERE p.owner_sub = :owner_sub
ORDER BY i.name ASC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchQuotes(int $instrumentId): array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 30'
);
$stmt->execute(['instrument_id' => $instrumentId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function saveInstrument(): string
{
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
if ($instrumentId <= 0) {
throw new RuntimeException('Bitte eine Aktie auswaehlen.');
}
$this->assertInstrumentAccessible($instrumentId);
$resolvedId = $this->instrumentRegistry->save([
'id' => $instrumentId,
'name' => $_POST['instrument_name'] ?? '',
'symbol' => $_POST['symbol'] ?? '',
'isin' => $_POST['isin'] ?? '',
'wkn' => $_POST['wkn'] ?? '',
'market' => $_POST['market'] ?? '',
'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency,
]);
$this->selectedInstrumentOverrideId = $resolvedId;
return $resolvedId === $instrumentId
? 'Aktie aktualisiert.'
: 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.';
}
private function saveQuote(): string
{
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
$price = (float) ($_POST['quote_price'] ?? 0);
if ($instrumentId <= 0 || $price <= 0) {
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
}
$this->assertInstrumentAccessible($instrumentId);
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')),
'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual',
]);
return 'Kurs gespeichert.';
}
private function deleteQuote(): string
{
$quoteId = (int) ($_POST['quote_id'] ?? 0);
if ($quoteId <= 0) {
throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.');
}
$stmt = $this->pdo->prepare(
'SELECT q.instrument_id
FROM ' . $this->quoteTable . ' q
WHERE q.id = :id
LIMIT 1'
);
$stmt->execute(['id' => $quoteId]);
$instrumentId = (int) $stmt->fetchColumn();
$this->assertInstrumentAccessible($instrumentId);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id');
$stmt->execute(['id' => $quoteId]);
return 'Kurs geloescht.';
}
private function refreshInstrumentQuote(): string
{
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
$instrument = $this->assertInstrumentAccessible($instrumentId);
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
if ($symbol === '') {
throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.');
}
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
if (empty($apiResult['ok'])) {
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
}
$quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
$displayTime = (string) \module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($apiResult['fetched_at'] ?? ''),
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
);
return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.';
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
);
return !empty($storeResult['inserted'])
? 'Alpha-Vantage-Kurs gespeichert.'
: 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.';
}
private function searchSymbol(): string
{
$this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? ''));
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords);
$this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
}
private function assertInstrumentAccessible(int $instrumentId): array
{
if ($instrumentId <= 0) {
throw new RuntimeException('Aktie nicht gefunden.');
}
$stmt = $this->pdo->prepare(
'SELECT DISTINCT i.*
FROM ' . $this->instrumentTable . ' i
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
WHERE i.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $instrumentId,
'owner_sub' => $this->ownerSub,
]);
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($instrument)) {
throw new RuntimeException('Aktie ist nicht verfuegbar.');
}
return $instrument;
}
private function normalizeDateTimeLocal(?string $value): string
{
$timezone = new \DateTimeZone(nexus_display_timezone_name());
$value = trim((string) $value);
if ($value === '') {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
if ($date instanceof \DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
try {
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
}
private function latestApiQuoteForInstrument(int $instrumentId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id
AND source LIKE :source
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'source' => 'alphavantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
{
if (!is_array($latestQuote)) {
return false;
}
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
if ($latestTimestamp === false || $incomingTimestamp === false) {
return false;
}
return $incomingTimestamp <= $latestTimestamp;
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class InstrumentRegistry
{
public function __construct(
private PDO $pdo,
private string $instrumentTable,
private string $positionTable,
private string $quoteTable,
) {
}
public function save(array $payload): int
{
$currentId = (int) ($payload['id'] ?? 0);
$data = $this->normalizePayload($payload);
$matchingId = $this->findMatchingInstrumentId($data, $currentId);
if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) {
return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data);
}
if ($currentId > 0) {
$this->updateInstrument($currentId, $data);
return $currentId;
}
if ($matchingId > 0) {
$this->updateInstrument($matchingId, $data);
return $matchingId;
}
return $this->insertInstrument($data);
}
public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int
{
$data = $this->normalizePayload($payload);
$conditions = [];
$excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : '';
if ($data['isin'] !== null) {
$conditions[] = [
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1',
'params' => ['isin' => $data['isin']],
];
}
if ($data['symbol'] !== null && $data['market'] !== null) {
$conditions[] = [
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1',
'params' => ['symbol' => $data['symbol'], 'market' => $data['market']],
];
}
if ($data['symbol'] !== null && $data['name'] !== '') {
$conditions[] = [
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1',
'params' => ['symbol' => $data['symbol'], 'name' => $data['name']],
];
}
foreach ($conditions as $condition) {
$params = $condition['params'];
if ($excludeId > 0) {
$params['exclude_id'] = $excludeId;
}
$stmt = $this->pdo->prepare($condition['sql']);
$stmt->execute($params);
$id = $stmt->fetchColumn();
if ($id !== false) {
return (int) $id;
}
}
return null;
}
private function normalizePayload(array $payload): array
{
$data = [
'isin' => $this->normalizeUpper($payload['isin'] ?? null),
'wkn' => $this->normalizeUpper($payload['wkn'] ?? null),
'symbol' => $this->normalizeUpper($payload['symbol'] ?? null),
'name' => trim((string) ($payload['name'] ?? '')),
'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'),
'market' => trim((string) ($payload['market'] ?? '')) ?: null,
];
if ($data['name'] === '') {
throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
}
return $data;
}
private function normalizeUpper(mixed $value, string $fallback = ''): ?string
{
$normalized = strtoupper(trim((string) $value));
if ($normalized !== '') {
return $normalized;
}
return $fallback !== '' ? $fallback : null;
}
private function updateInstrument(int $instrumentId, array $data): void
{
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->instrumentTable . '
SET isin = :isin,
wkn = :wkn,
symbol = :symbol,
name = :name,
quote_currency = :quote_currency,
market = :market,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id'
);
$stmt->execute($data + ['id' => $instrumentId]);
}
private function insertInstrument(array $data): int
{
$driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
RETURNING id'
);
$stmt->execute($data);
return (int) $stmt->fetchColumn();
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int
{
$this->pdo->beginTransaction();
try {
$this->updateInstrument($targetId, $data);
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->positionTable . '
SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP
WHERE instrument_id = :source_id'
);
$stmt->execute([
'target_id' => $targetId,
'source_id' => $sourceId,
]);
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->quoteTable . '
SET instrument_id = :target_id
WHERE instrument_id = :source_id'
);
$stmt->execute([
'target_id' => $targetId,
'source_id' => $sourceId,
]);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id');
$stmt->execute(['id' => $sourceId]);
$this->pdo->commit();
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
return $targetId;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/fx-rates/index.php/';
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
if ($relativePath === '') {
$relativePath = str_starts_with($requestPath, $prefix)
? substr($requestPath, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
}
(new Modules\FxRates\Api\Router(dirname(__DIR__)))->handle($relativePath);

View File

@@ -0,0 +1,206 @@
#fx-rates-app {
height: 100%;
}
#fx-rates-app .fx-shell {
height: 100%;
}
#fx-rates-app .fx-stack {
display: grid;
gap: 18px;
}
#fx-rates-app .fx-grid,
#fx-rates-app .fx-cards,
#fx-rates-app .fx-table-grid {
display: grid;
gap: 18px;
}
#fx-rates-app .fx-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#fx-rates-app .fx-cards {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
#fx-rates-app .fx-table-grid {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
}
#fx-rates-app .fx-card,
#fx-rates-app .fx-panel {
padding: 22px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
}
#fx-rates-app .fx-card h3,
#fx-rates-app .fx-panel h3,
#fx-rates-app .fx-panel h4 {
margin: 0 0 10px;
}
#fx-rates-app .fx-copy,
#fx-rates-app .fx-meta,
#fx-rates-app .fx-label,
#fx-rates-app .fx-note {
margin: 0;
color: #475569;
line-height: 1.5;
}
#fx-rates-app .fx-kicker {
margin: 0 0 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.18em;
color: #4338ca;
font-weight: 700;
}
#fx-rates-app .fx-stat-value {
font-size: 1.45rem;
font-weight: 700;
color: #0f172a;
}
#fx-rates-app .fx-inline-actions,
#fx-rates-app .fx-form-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
#fx-rates-app .fx-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid rgba(79, 70, 229, 0.18);
background: linear-gradient(135deg, #4f46e5, #2563eb);
color: #ffffff;
font: inherit;
font-weight: 600;
cursor: pointer;
}
#fx-rates-app .fx-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#fx-rates-app .fx-button--ghost {
background: rgba(255, 255, 255, 0.88);
color: #1e1b4b;
}
#fx-rates-app .fx-message {
min-height: 1.5rem;
color: #1e293b;
}
#fx-rates-app .fx-message.is-error {
color: #b91c1c;
}
#fx-rates-app .fx-message.is-success {
color: #0f766e;
}
#fx-rates-app .fx-form-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#fx-rates-app .fx-field {
display: grid;
gap: 8px;
}
#fx-rates-app .fx-field label {
font-size: 13px;
color: #475569;
font-weight: 600;
}
#fx-rates-app .fx-input,
#fx-rates-app .fx-select {
width: 100%;
min-height: 44px;
padding: 0 14px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: #ffffff;
color: #0f172a;
font: inherit;
}
#fx-rates-app .fx-input:focus,
#fx-rates-app .fx-select:focus {
outline: none;
border-color: rgba(79, 70, 229, 0.5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
}
#fx-rates-app .fx-token-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
#fx-rates-app .fx-token {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(79, 70, 229, 0.08);
color: #312e81;
border: 1px solid rgba(79, 70, 229, 0.14);
font-size: 14px;
}
#fx-rates-app .fx-table-wrap {
overflow: auto;
}
#fx-rates-app .fx-table {
width: 100%;
border-collapse: collapse;
}
#fx-rates-app .fx-table th,
#fx-rates-app .fx-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
vertical-align: top;
}
#fx-rates-app .fx-table th {
color: #475569;
font-size: 13px;
}
#fx-rates-app .fx-widget-note {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
@media (max-width: 1100px) {
#fx-rates-app .fx-table-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,780 @@
(function () {
function parseSections(root, options) {
const configured = Array.isArray(options.sections) ? options.sections : null;
if (configured) {
return configured;
}
try {
const parsed = JSON.parse(root.dataset.sectionsJson || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
function cx() {
return Array.from(arguments).filter(Boolean).join(' ');
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function fmtNumber(value, digits) {
if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) {
return 'n/a';
}
return Number(value).toLocaleString('de-DE', {
minimumFractionDigits: 0,
maximumFractionDigits: digits === undefined ? 6 : digits,
});
}
function fmtDate(value) {
const raw = String(value || '').trim();
if (!raw) {
return 'n/a';
}
let normalized = raw;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
normalized = `${raw}T00:00:00Z`;
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
normalized = `${raw.replace(' ', 'T')}Z`;
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
normalized = `${raw}Z`;
}
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return raw;
}
return parsed.toLocaleString('de-DE');
}
function readMessage(error) {
return error && error.message ? error.message : 'Unbekannter Fehler';
}
function createFxRatesApp(root, options) {
const apiBase = options.apiBase || root.dataset.apiBase || '/api/fx-rates/index.php?path=v1';
const sectionDefs = parseSections(root, options);
const fallbackSections = [
{ key: 'overview', label: 'Uebersicht', summary: 'Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung.' },
{ key: 'currencies', label: 'Waehrungen', summary: 'Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten.' },
{ key: 'settings', label: 'Setup', summary: 'Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen.' },
];
const sectionMap = new Map();
sectionDefs.concat(fallbackSections).forEach((section) => {
const key = String(section.key || '').trim();
if (!key || sectionMap.has(key)) {
return;
}
sectionMap.set(key, {
key,
label: String(section.label || key),
summary: String(section.summary || ''),
});
});
const sections = Array.from(sectionMap.values());
const initialTab = String(options.activeView || root.dataset.activeView || 'overview').trim() || 'overview';
const state = {
activeTab: sectionMap.has(initialTab) ? initialTab : sections[0].key,
loading: true,
saving: false,
syncingCatalog: false,
message: '',
error: '',
settings: null,
statuses: [],
recentFetches: [],
conversion: null,
history: [],
currencyProbe: null,
};
function setState(patch) {
Object.assign(state, patch);
render();
}
async function request(path, options) {
const buildApiUrl = (relativePath) => {
const url = new URL(apiBase, window.location.origin);
const [resourcePath, queryString] = String(relativePath || '').split('?');
const currentPath = url.searchParams.get('path') || '';
url.searchParams.set('path', `${currentPath.replace(/\/+$/, '')}/${String(resourcePath || '').replace(/^\/+/, '')}`.replace(/^\/+/, ''));
if (queryString) {
const extraParams = new URLSearchParams(queryString);
extraParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
}
return url.toString();
};
const response = await fetch(buildApiUrl(path), {
method: options && options.method ? options.method : 'GET',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(options && options.body ? { 'Content-Type': 'application/json' } : {}),
},
body: options && options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
}
return payload.data;
}
function currentSettings() {
return state.settings || {
preferred_currencies: ['EUR', 'USD'],
default_base_currency: 'EUR',
display_base_currency: 'EUR',
refresh_max_age_hours: 6,
refresh_max_age_minutes: 360,
currency_catalog: [],
};
}
function historyPairs() {
const settings = currentSettings();
const base = String(settings.display_base_currency || settings.default_base_currency || 'EUR').toUpperCase();
const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : [];
return preferred
.map((code) => String(code || '').toUpperCase())
.filter((code) => code && code !== base)
.slice(0, 4)
.map((code) => ({ from: base, to: code }));
}
function normalizedTimestamp(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
return `${raw.replace(' ', 'T')}Z`;
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
return `${raw}Z`;
}
return raw;
}
function buildHistoryMatrix(historyEntries) {
const columns = [];
const rowsByKey = new Map();
historyEntries.forEach((entry) => {
const pair = entry && entry.pair ? entry.pair : null;
const columnKey = pair ? `${String(pair.from || '').toUpperCase()}/${String(pair.to || '').toUpperCase()}` : '';
if (!columnKey) {
return;
}
if (!columns.includes(columnKey)) {
columns.push(columnKey);
}
(Array.isArray(entry.rows) ? entry.rows : []).forEach((row) => {
const fetchId = Number(row && row.fetch_id);
const fetchedAt = normalizedTimestamp(row && row.fetched_at);
const rowKey = fetchId > 0 ? `fetch:${fetchId}` : `time:${fetchedAt}`;
if (!rowKey || rowKey === 'time:') {
return;
}
if (!rowsByKey.has(rowKey)) {
rowsByKey.set(rowKey, {
key: rowKey,
fetch_id: fetchId > 0 ? fetchId : null,
fetched_at: row && row.fetched_at ? String(row.fetched_at) : '',
provider: row && row.provider ? String(row.provider) : '',
values: {},
});
}
const bucket = rowsByKey.get(rowKey);
if (!bucket) {
return;
}
if (!bucket.fetched_at && row && row.fetched_at) {
bucket.fetched_at = String(row.fetched_at);
}
if (!bucket.provider && row && row.provider) {
bucket.provider = String(row.provider);
}
bucket.values[columnKey] = Number.isFinite(Number(row && row.rate)) ? Number(row.rate) : null;
});
});
const rows = Array.from(rowsByKey.values()).sort((left, right) => {
const leftTs = new Date(normalizedTimestamp(left.fetched_at) || 0).getTime();
const rightTs = new Date(normalizedTimestamp(right.fetched_at) || 0).getTime();
return rightTs - leftTs;
});
return {
columns,
rows,
};
}
async function loadOverviewData() {
const [statuses, recentFetches] = await Promise.all([
request('status'),
request('recent-fetches?limit=15'),
]);
const pairs = historyPairs();
const historyRows = await Promise.all(pairs.map(async (pair) => ({
pair,
rows: await request(`history?from=${encodeURIComponent(pair.from)}&to=${encodeURIComponent(pair.to)}&limit=12`),
})));
setState({
statuses: Array.isArray(statuses) ? statuses : [],
recentFetches: Array.isArray(recentFetches) ? recentFetches : [],
history: historyRows,
});
}
async function loadBootstrap() {
try {
const [settings, currencyProbe] = await Promise.all([
request('settings'),
request('currency-catalog/probe').catch(() => null),
]);
setState({
settings,
currencyProbe,
loading: false,
error: '',
});
await loadOverviewData();
} catch (error) {
setState({
loading: false,
error: readMessage(error),
});
}
}
async function runRefresh(force) {
setState({
saving: true,
error: '',
message: '',
});
try {
const result = await request('refresh', {
method: 'POST',
body: {
force: force === true,
trigger_source: 'manual',
},
});
await loadOverviewData();
setState({
saving: false,
message: result && result.reused
? `Kein neuer Abruf. Letzter Snapshot ist noch innerhalb von ${fmtNumber(currentSettings().refresh_max_age_hours, 2)} Stunden.`
: `Aktuelle Kurse gespeichert. ${fmtNumber(result?.updated_count, 0)} Werte aktualisiert.`,
});
} catch (error) {
setState({
saving: false,
error: readMessage(error),
});
}
}
async function runConversion(event) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const amount = Number(formData.get('amount') || 0);
const from = String(formData.get('from') || '').toUpperCase();
const to = String(formData.get('to') || '').toUpperCase();
try {
const rate = await request(`rate?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
const resolved = Number(rate?.rate || 0);
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new Error('Kein passender Kurs verfuegbar.');
}
setState({
conversion: {
amount,
from,
to,
rate: resolved,
converted: amount * resolved,
},
error: '',
message: '',
});
} catch (error) {
setState({
conversion: null,
error: readMessage(error),
});
}
}
async function saveSettings(event) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const preferred = String(formData.get('preferred_currencies') || '')
.split(',')
.map((item) => String(item || '').trim().toUpperCase())
.filter(Boolean);
setState({ saving: true, error: '', message: '' });
try {
const body = {};
['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'].forEach((key) => {
if (formData.has(key)) {
body[key] = formData.get(key);
}
});
if (formData.has('timeout_sec')) {
body.timeout_sec = Number(formData.get('timeout_sec') || 10);
}
if (formData.has('refresh_max_age_hours')) {
body.refresh_max_age_hours = Number(formData.get('refresh_max_age_hours') || 6);
}
if (formData.has('preferred_currencies')) {
body.preferred_currencies = preferred;
}
const settings = await request('settings', {
method: 'PUT',
body,
});
setState({
saving: false,
settings,
message: 'Setup gespeichert.',
});
await loadOverviewData();
} catch (error) {
setState({
saving: false,
error: readMessage(error),
});
}
}
async function syncCatalog() {
setState({ syncingCatalog: true, error: '', message: '' });
try {
const result = await request('currency-catalog/sync', {
method: 'POST',
body: {},
});
setState({
syncingCatalog: false,
settings: result?.settings || state.settings,
message: `Waehrungskatalog synchronisiert. ${fmtNumber(result?.sync?.synced_count, 0)} Eintraege geladen.`,
});
} catch (error) {
setState({
syncingCatalog: false,
error: readMessage(error),
});
}
}
function openWidgetSetup() {
window.dispatchEvent(new CustomEvent('desktop:open-app', {
detail: {
appId: 'user-self-management',
},
}));
}
function renderNav() {
return `
<aside class="window-app-sidebar">
<div class="window-app-brand">
<p class="window-app-kicker">Modul</p>
<h1>Waehrungs-Checker</h1>
<p>Waehrungskurse, Historie, API und Widget-Aktualisierung in einer gemeinsamen Modulanwendung.</p>
</div>
<div class="window-app-nav-list">
${sections.map((section) => `
<button class="${cx('window-app-nav-button', state.activeTab === section.key && 'is-active')}" type="button" data-action="tab" data-tab="${escapeHtml(section.key)}">
<strong>${escapeHtml(section.label)}</strong>
<span class="window-app-meta">${escapeHtml(section.summary)}</span>
</button>
`).join('')}
</div>
</aside>
`;
}
function renderOverview() {
const settings = currentSettings();
const latest = state.statuses[0] || null;
const base = String(settings.default_base_currency || 'EUR').toUpperCase();
const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : [];
const historyMatrix = buildHistoryMatrix(state.history);
return `
<div class="fx-stack">
<section class="window-app-card">
<p class="window-app-kicker">Aktualisierung</p>
<h3>Externer Kursabruf</h3>
<p class="window-app-copy">Das Oeffnen der App loest keinen Abruf aus. Manuell und per API wird nur aktualisiert, wenn die bestehenden Kurse aelter als ${fmtNumber(settings.refresh_max_age_hours, 2)} Stunden sind, ausser mit force.</p>
<div class="fx-inline-actions">
<button class="fx-button" type="button" data-action="refresh">${state.saving ? 'Aktualisiert ...' : 'Kurse aktualisieren'}</button>
<button class="fx-button fx-button--ghost" type="button" data-action="refresh-force">${state.saving ? 'Aktualisiert ...' : 'Erzwungen aktualisieren'}</button>
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Widget-Auswahl oeffnen</button>
</div>
<p class="${cx('fx-message', state.error && 'is-error', state.message && !state.error && 'is-success')}">${escapeHtml(state.error || state.message || '')}</p>
</section>
<section class="fx-cards">
<article class="fx-card">
<p class="fx-kicker">Letzter Abruf</p>
<div class="fx-stat-value">${escapeHtml(latest ? fmtDate(latest.fetched_at) : 'Noch keiner')}</div>
<p class="fx-copy">${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}</p>
</article>
<article class="fx-card">
<p class="fx-kicker">Standard-Basis</p>
<div class="fx-stat-value">${escapeHtml(base)}</div>
<p class="fx-copy">Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}</p>
</article>
<article class="fx-card">
<p class="fx-kicker">Bevorzugte Waehrungen</p>
<div class="fx-stat-value">${escapeHtml(String(preferred.length || 0))}</div>
<p class="fx-copy">${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}</p>
</article>
</section>
<section class="fx-table-grid">
<section class="fx-panel">
<p class="fx-kicker">Umrechnung</p>
<h3>Direkter Kursvergleich</h3>
<form class="fx-form-grid" data-form="convert">
<div class="fx-field">
<label for="fx-amount">Betrag</label>
<input id="fx-amount" class="fx-input" type="number" name="amount" min="0" step="0.00000001" value="1">
</div>
<div class="fx-field">
<label for="fx-from">Von</label>
<select id="fx-from" class="fx-select" name="from">
${preferred.map((currency) => `<option value="${escapeHtml(currency)}">${escapeHtml(currency)}</option>`).join('')}
</select>
</div>
<div class="fx-field">
<label for="fx-to">Nach</label>
<select id="fx-to" class="fx-select" name="to">
${preferred.map((currency, index) => `<option value="${escapeHtml(currency)}" ${index === 1 ? 'selected' : ''}>${escapeHtml(currency)}</option>`).join('')}
</select>
</div>
<div class="fx-form-actions">
<button class="fx-button" type="submit">Umrechnen</button>
</div>
</form>
<p class="fx-copy">${state.conversion
? `${fmtNumber(state.conversion.amount, 8)} ${state.conversion.from} = ${fmtNumber(state.conversion.converted, 8)} ${state.conversion.to} bei Kurs ${fmtNumber(state.conversion.rate, 8)}`
: 'Noch keine Umrechnung berechnet.'}</p>
</section>
<section class="fx-panel">
<p class="fx-kicker">Widget</p>
<h3>Desktop-Infobereich</h3>
<div class="fx-widget-note">
<p class="fx-copy">Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.</p>
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Setup oeffnen</button>
</div>
</section>
</section>
<section class="fx-panel">
<p class="fx-kicker">Verlauf</p>
<h3>Neueste Kursverlaeufe</h3>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<th>Quelle</th>
${historyMatrix.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}
</tr>
</thead>
<tbody>
${historyMatrix.rows.length === 0
? `<tr><td colspan="${String(2 + historyMatrix.columns.length)}">Noch keine Verlaufsdaten vorhanden.</td></tr>`
: historyMatrix.rows.map((row) => `
<tr>
<td>${escapeHtml(fmtDate(row.fetched_at))}</td>
<td>${escapeHtml(row.provider || 'n/a')}</td>
${historyMatrix.columns.map((column) => `<td>${escapeHtml(fmtNumber(row.values[column], 8))}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
</section>
<section class="fx-panel">
<p class="fx-kicker">Abrufe</p>
<h3>Letzte gespeicherte Snapshots</h3>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<th>Basis</th>
<th>Provider</th>
<th>Ausloeser</th>
</tr>
</thead>
<tbody>
${state.recentFetches.length === 0
? '<tr><td colspan="4">Noch keine Abrufe gespeichert.</td></tr>'
: state.recentFetches.map((fetch) => `
<tr>
<td>${escapeHtml(fmtDate(fetch.fetched_at))}</td>
<td>${escapeHtml(fetch.base_currency || 'n/a')}</td>
<td>${escapeHtml(fetch.provider || 'n/a')}</td>
<td>${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</section>
</div>
`;
}
function renderCurrencies() {
const settings = currentSettings();
const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : [];
return `
<div class="fx-stack">
<section class="window-app-card">
<p class="window-app-kicker">Katalog</p>
<h3>Bevorzugte Waehrungen</h3>
<p class="window-app-copy">Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.</p>
<div class="fx-inline-actions">
<button class="fx-button" type="button" data-action="sync-catalog">${state.syncingCatalog ? 'Synchronisiert ...' : 'Waehrungskatalog synchronisieren'}</button>
</div>
<p class="fx-copy">Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}</p>
</section>
<form class="fx-panel fx-stack" data-form="settings">
<div class="fx-form-grid">
<div class="fx-field">
<label for="fx-display-base">Anzeige-Basis</label>
<input id="fx-display-base" class="fx-input" type="text" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
</div>
<div class="fx-field">
<label for="fx-default-base">Standard-Basis</label>
<input id="fx-default-base" class="fx-input" type="text" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
</div>
<div class="fx-field">
<label for="fx-preferred">Bevorzugte Waehrungen</label>
<input id="fx-preferred" class="fx-input" type="text" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
</div>
</div>
<div class="fx-form-actions">
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Waehrungen speichern'}</button>
</div>
</form>
<section class="fx-panel">
<p class="fx-kicker">Verfuegbarer Katalog</p>
<div class="fx-token-row">
${catalog.length === 0
? '<span class="fx-token">Noch kein Katalog geladen</span>'
: catalog.map((item) => `<span class="fx-token">${escapeHtml(item.code)} · ${escapeHtml(item.name)}</span>`).join('')}
</div>
</section>
</div>
`;
}
function renderSettings() {
const settings = currentSettings();
return `
<form class="fx-stack" data-form="settings">
<section class="window-app-card">
<p class="window-app-kicker">Provider</p>
<h3>Externe Quelle und Token</h3>
<div class="fx-form-grid">
<div class="fx-field">
<label for="fx-provider">Provider</label>
<input id="fx-provider" class="fx-input" type="text" name="provider" value="${escapeHtml(settings.provider || 'currencyapi')}">
</div>
<div class="fx-field">
<label for="fx-api-version">API Version</label>
<select id="fx-api-version" class="fx-select" name="api_version">
<option value="v2" ${settings.api_version === 'v2' ? 'selected' : ''}>v2</option>
<option value="v3" ${settings.api_version === 'v3' ? 'selected' : ''}>v3</option>
</select>
</div>
<div class="fx-field">
<label for="fx-api-url">API URL</label>
<input id="fx-api-url" class="fx-input" type="text" name="api_url" value="${escapeHtml(settings.api_url || '')}">
</div>
<div class="fx-field">
<label for="fx-currencies-url">Currencies URL</label>
<input id="fx-currencies-url" class="fx-input" type="text" name="currencies_url" value="${escapeHtml(settings.currencies_url || settings.api_url || '')}">
</div>
<div class="fx-field">
<label for="fx-api-key">API Key</label>
<input id="fx-api-key" class="fx-input" type="password" name="api_key" value="${escapeHtml(settings.api_key || '')}">
</div>
<div class="fx-field">
<label for="fx-timeout">Timeout (Sek.)</label>
<input id="fx-timeout" class="fx-input" type="number" name="timeout_sec" min="2" step="1" value="${escapeHtml(String(settings.timeout_sec || 10))}">
</div>
<div class="fx-field">
<label for="fx-refresh-hours">Refresh-Sperre (Std.)</label>
<input id="fx-refresh-hours" class="fx-input" type="number" name="refresh_max_age_hours" min="1" step="0.5" value="${escapeHtml(String(settings.refresh_max_age_hours || 6))}">
</div>
<div class="fx-field">
<label for="fx-timezone">Zeitzone</label>
<input id="fx-timezone" class="fx-input" type="text" name="schedule_timezone" value="${escapeHtml(settings.schedule_timezone || 'Europe/Berlin')}">
</div>
<input type="hidden" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
<input type="hidden" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
<input type="hidden" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
</div>
<div class="fx-form-actions">
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Setup speichern'}</button>
</div>
<p class="fx-copy">Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.</p>
</section>
</form>
`;
}
function renderMain() {
const current = sectionMap.get(state.activeTab) || sections[0];
let content = '';
if (state.activeTab === 'currencies') {
content = renderCurrencies();
} else if (state.activeTab === 'settings') {
content = renderSettings();
} else {
content = renderOverview();
}
return `
<main class="window-app-main">
<section class="window-app-hero">
<div>
<p class="window-app-kicker">Waehrungs-Checker</p>
<h2 class="window-app-title">${escapeHtml(current.label)}</h2>
<p class="window-app-copy">${escapeHtml(current.summary)}</p>
</div>
<div class="window-app-pill-row">
<span class="window-app-pill">API aktiv</span>
<span class="window-app-pill">Widget optional</span>
<span class="window-app-pill">Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))}</span>
</div>
</section>
<div class="window-app-panel fx-shell">
${content}
</div>
</main>
`;
}
function bind(rootNode) {
rootNode.querySelectorAll('[data-action="tab"]').forEach((button) => {
button.addEventListener('click', () => {
setState({ activeTab: String(button.getAttribute('data-tab') || 'overview') });
});
});
rootNode.querySelectorAll('[data-action="refresh"]').forEach((button) => {
button.addEventListener('click', () => runRefresh(false));
});
rootNode.querySelectorAll('[data-action="refresh-force"]').forEach((button) => {
button.addEventListener('click', () => {
const confirmed = window.confirm('Der Abruf wird jetzt unabhaengig vom Alter der gespeicherten Kurse erzwungen. Wirklich fortfahren?');
if (confirmed) {
runRefresh(true);
}
});
});
rootNode.querySelectorAll('[data-action="sync-catalog"]').forEach((button) => {
button.addEventListener('click', () => syncCatalog());
});
rootNode.querySelectorAll('[data-action="open-widget-setup"]').forEach((button) => {
button.addEventListener('click', () => openWidgetSetup());
});
rootNode.querySelectorAll('[data-form="convert"]').forEach((form) => {
form.addEventListener('submit', runConversion);
});
rootNode.querySelectorAll('[data-form="settings"]').forEach((form) => {
form.addEventListener('submit', saveSettings);
});
}
function render() {
if (state.loading) {
root.innerHTML = '<div class="window-app-loading"><p class="window-app-copy">Waehrungsdaten werden geladen.</p></div>';
return;
}
if (state.error && !state.settings) {
root.innerHTML = `<div class="window-app-error-state"><p class="window-app-message is-error">${escapeHtml(state.error)}</p></div>`;
return;
}
root.innerHTML = `
<div class="window-app-shell">
<div class="window-app-frame">
${renderNav()}
${renderMain()}
</div>
</div>
`;
bind(root);
}
render();
loadBootstrap();
}
window.initFxRatesApp = function initFxRatesApp(rootNode, options) {
const root = rootNode || document.getElementById('fx-rates-app');
if (!root || root.dataset.moduleInitialized === '1') {
return;
}
root.dataset.moduleInitialized = '1';
createFxRatesApp(root, options && typeof options === 'object' ? options : {});
};
if (document.getElementById('fx-rates-app')) {
window.initFxRatesApp(document.getElementById('fx-rates-app'), {});
}
})();

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\FxRates\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require_once $path;
}
});
$moduleName = 'fx-rates';
$moduleBasePath = __DIR__;
if (method_exists(modules(), 'registerFunction')) {
modules()->registerFunction($moduleName, 'service', static function () use ($moduleBasePath): \Modules\FxRates\Domain\FxRatesService {
static $service = null;
if ($service instanceof \Modules\FxRates\Domain\FxRatesService) {
return $service;
}
$moduleConfig = \Modules\FxRates\Infrastructure\ModuleConfig::load($moduleBasePath);
$settingsStore = new \Modules\FxRates\Infrastructure\SettingsStore($moduleConfig);
$settings = $settingsStore->load();
$pdo = \Modules\FxRates\Infrastructure\ConnectionFactory::make($settingsStore);
$repository = new \Modules\FxRates\Infrastructure\FxRatesRepository(
$pdo,
$moduleConfig->tablePrefix()
);
$repository->ensureSchema();
$service = new \Modules\FxRates\Domain\FxRatesService($repository, $settings);
return $service;
});
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
return [
'FX_RATES_PROVIDER' => 'currencyapi',
'FX_RATES_API_VERSION' => 'v2',
'FX_RATES_API_URL' => 'https://currencyapi.net',
'FX_RATES_CURRENCIES_URL' => 'https://currencyapi.net',
'FX_RATES_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
'FX_RATES_TIMEOUT' => '10',
'FX_RATES_REFRESH_MAX_AGE_HOURS' => '6',
'FX_RATES_DEFAULT_BASE_CURRENCY' => 'EUR',
'FX_RATES_DISPLAY_BASE_CURRENCY' => 'EUR',
'FX_RATES_SCHEDULE_TIMEZONE' => 'Europe/Berlin',
];

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'default_project_key' => getenv('FX_RATES_DEFAULT_PROJECT_KEY') ?: 'fx-main',
'use_project_database' => true,
'table_prefix' => 'fxrate_',
'settings_file' => dirname(__DIR__, 3) . '/data/fx-rates/settings.json',
'provider' => getenv('FX_RATES_PROVIDER') ?: (getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi'),
'api_version' => getenv('FX_RATES_API_VERSION') ?: 'v2',
'api_url' => getenv('FX_RATES_API_URL') ?: (getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net'),
'currencies_url' => getenv('FX_RATES_CURRENCIES_URL') ?: (getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: (getenv('FX_RATES_API_URL') ?: (getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net'))),
'api_key' => getenv('FX_RATES_API_KEY') ?: (getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1'),
'timeout_sec' => (int) (getenv('FX_RATES_TIMEOUT') ?: (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)),
'refresh_max_age_hours' => (float) (getenv('FX_RATES_REFRESH_MAX_AGE_HOURS') ?: 6),
'default_base_currency' => getenv('FX_RATES_DEFAULT_BASE_CURRENCY') ?: 'EUR',
'display_base_currency' => getenv('FX_RATES_DISPLAY_BASE_CURRENCY') ?: (getenv('FX_RATES_DEFAULT_BASE_CURRENCY') ?: 'EUR'),
'preferred_currencies' => ['EUR', 'USD', 'DOGE', 'BTC'],
'currency_catalog' => [],
'currency_catalog_synced_at' => '',
'schedule_timezone' => getenv('FX_RATES_SCHEDULE_TIMEZONE') ?: nexus_display_timezone_name(),
];

View File

@@ -0,0 +1,19 @@
{
"sections": [
{
"key": "overview",
"label": "Uebersicht",
"summary": "Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung."
},
{
"key": "currencies",
"label": "Waehrungen",
"summary": "Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten."
},
{
"key": "settings",
"label": "Setup",
"summary": "Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen."
}
]
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
$design = [];
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$sections = array_values(array_filter(
is_array($design['sections'] ?? null) ? $design['sections'] : [],
static fn (mixed $section): bool => is_array($section)
));
$assetVersion = static function (string $relativePath): string {
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
return [
'app_id' => 'fx-rates',
'title' => (string) ($manifest['title'] ?? 'Waehrungs-Checker'),
'icon' => 'FX',
'entry_route' => '/apps/fx-rates',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1120,
'default_height' => 760,
'supports_widget' => true,
'supports_tray' => true,
'module_name' => 'finance',
'summary' => (string) ($manifest['description'] ?? 'Waehrungskurse, Verlauf, API und Desktop-Widget fuer manuelle Aktualisierung.'),
'native_module' => [
'initializer' => 'initFxRatesApp',
'options' => [
'apiBase' => '/api/fx-rates/index.php?path=v1',
'activeView' => 'overview',
'sections' => $sections,
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=fx-rates&path=assets/css/app.css&v=' . rawurlencode($assetVersion('assets/css/app.css'))],
],
'scripts' => [
['src' => '/module-assets/index.php?module=fx-rates&path=assets/js/app.js&v=' . rawurlencode($assetVersion('assets/js/app.js'))],
],
],
],
];

View File

@@ -0,0 +1,29 @@
# Waehrungs-Checker Modul
Das Modul `fx-rates` stellt gespeicherte Waehrungskurse, Historie, Umrechnung und manuelle Refresh-Aktionen bereit.
## Kernpunkte
- Desktop-App unter `custom/apps/fx-rates/desktop.php`
- API unter `/api/fx-rates/...`
- Widget fuer den rechten Desktop-Infobereich ueber `config/widgets.php`
- externer Provider-Token wird aus dem alten Bestand als Default uebernommen
- oeffentliche Einstiege laufen ueber `public/apps/fx-rates/index.php` und `public/api/fx-rates/index.php`
## Refresh-Regeln
- das Oeffnen der App loest keinen externen Abruf aus
- `POST /api/fx-rates/.../refresh` aktualisiert nur, wenn der letzte Snapshot aelter als die konfigurierte Sperrzeit ist
- mit `force=true` kann ein Abruf explizit erzwungen werden
- das Desktop-Widget nutzt dieselbe Logik
## API
Wichtige Endpunkte:
- `GET /api/fx-rates/v1/latest`
- `GET /api/fx-rates/v1/rate`
- `GET /api/fx-rates/v1/history`
- `POST /api/fx-rates/v1/refresh`
- `GET /api/fx-rates/v1/settings`
- `PUT /api/fx-rates/v1/settings`

View File

@@ -0,0 +1,90 @@
{
"name": "fx-rates",
"title": "Waehrungs-Checker",
"version": "0.1.0",
"description": "Zentrales Modul fuer Waehrungskurse, Historie, API-Abrufe und Desktop-Widget.",
"enabled_by_default": true,
"app_scope": "module",
"installable": true,
"desktop": {
"available": true,
"show_on_desktop": true,
"show_in_start_menu": true
},
"widgets": [
{
"widget_id": "fx-rates-refresh",
"title": "Waehrungskurse",
"icon": "FX",
"zone": "sidebar",
"default_enabled": true,
"supports_public_home": false,
"summary": "Aktuelle FX-Kurse pruefen und bei Bedarf manuell aktualisieren.",
"content": "Ein API-Abruf wird nur ausgefuehrt, wenn der letzte Snapshot alt genug ist, ausser bei explizitem Force.",
"launch_app_id": "fx-rates",
"action_label": "App oeffnen",
"widget_type": "fx-rates-refresh",
"status_api": "/api/fx-rates/index.php?path=v1/status",
"refresh_api": "/api/fx-rates/index.php?path=v1/refresh"
}
],
"cron_jobs": [
{
"job_id": "refresh-rates",
"label": "FX-Kurse aktualisieren",
"description": "Laedt neue Wechselkurse, sofern der letzte Snapshot nicht mehr frisch genug ist.",
"endpoint_path": "/api/fx-rates/index.php?path=v1/refresh",
"method": "POST",
"payload": {
"trigger_source": "cron"
},
"default_enabled": false,
"default_cron": "0 */6 * * *",
"lock_minutes": 15
},
{
"job_id": "sync-currency-catalog",
"label": "FX-Waehrungskatalog synchronisieren",
"description": "Aktualisiert den internen Waehrungskatalog des Moduls.",
"endpoint_path": "/api/fx-rates/index.php?path=v1/currency-catalog/sync",
"method": "POST",
"payload": {
"trigger_source": "cron"
},
"default_enabled": false,
"default_cron": "30 3 * * 1",
"lock_minutes": 30
}
],
"setup": {
"sections": {
"database": true
},
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Projekt-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." },
{ "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." },
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
{ "name": "currencies_url", "label": "FX Waehrungs-URL", "type": "text", "required": false, "help": "Optional separate Basis-URL fuer den Waehrungskatalog." },
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
{ "name": "refresh_max_age_hours", "label": "Max. Alter fuer API-Refresh (Std.)", "type": "number", "required": false, "help": "Blockiert neue manuelle/API-Refreshes, solange der letzte gespeicherte Abruf juenger ist. Nur mit force darf frueher aktualisiert werden." },
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false },
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "text", "required": false },
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "text", "required": false, "help": "Kommagetrennte Liste, z.B. EUR,USD,DOGE,BTC." },
{ "name": "schedule_timezone", "label": "Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
]
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$design = [];
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$assetVersion = static function (string $relativePath): string {
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
$sectionKeys = array_values(array_filter(array_map(
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
$sections
)));
if ($sectionKeys === []) {
$sectionKeys = ['overview'];
}
if (!in_array($activeView, $sectionKeys, true)) {
$activeView = $sectionKeys[0];
}
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Waehrungs-Checker</title>
<link rel="stylesheet" href="/assets/desktop/desktop.css">
<link rel="stylesheet" href="/module-assets/index.php?module=fx-rates&amp;path=assets/css/app.css&amp;v=<?= htmlspecialchars($assetVersion('assets/css/app.css'), ENT_QUOTES) ?>">
</head>
<body>
<div
id="fx-rates-app"
data-api-base="/api/fx-rates/index.php?path=v1"
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
></div>
<script src="/module-assets/index.php?module=fx-rates&amp;path=assets/js/app.js&amp;v=<?= htmlspecialchars($assetVersion('assets/js/app.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Api;
use Modules\FxRates\Domain\FxRatesService;
use Modules\FxRates\Infrastructure\ConnectionFactory;
use Modules\FxRates\Infrastructure\FxRatesRepository;
use Modules\FxRates\Infrastructure\ModuleConfig;
use Modules\FxRates\Infrastructure\SettingsStore;
use RuntimeException;
final class Router
{
private ModuleConfig $moduleConfig;
private SettingsStore $settingsStore;
private ?FxRatesService $service = null;
public function __construct(
private readonly string $moduleBasePath
) {
$this->moduleConfig = ModuleConfig::load($moduleBasePath);
$this->settingsStore = new SettingsStore($this->moduleConfig);
}
public function handle(string $relativePath): never
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = trim($relativePath, '/');
try {
if ($path === 'v1/health' && $method === 'GET') {
$this->respond(['ok' => true, 'module' => 'fx-rates']);
}
if ($path === 'v1/endpoints' && $method === 'GET') {
$this->respond(['data' => $this->endpointCatalog()]);
}
if ($path === 'v1/status' && $method === 'GET') {
$this->respond(['data' => $this->service()->latestStatuses()]);
}
if ($path === 'v1/recent-fetches' && $method === 'GET') {
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 12)));
$this->respond(['data' => $this->service()->recentFetches($limit)]);
}
if ($path === 'v1/latest' && $method === 'GET') {
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$base = $this->stringOrNull($_GET['base'] ?? null);
if ($symbols === null) {
$settings = $this->settingsStore->load();
$symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null;
}
$snapshot = $this->service()->snapshot($base, null, $symbols, null);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/fetch' && $method === 'GET') {
$fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0));
$base = $this->stringOrNull($_GET['base'] ?? null);
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$snapshot = $this->service()->snapshotByFetchId($fetchId, $base, $symbols);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/nearest' && $method === 'GET') {
$base = $this->stringOrNull($_GET['base'] ?? null);
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$snapshot = $this->service()->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/snapshot' && $method === 'GET') {
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$base = $this->stringOrNull($_GET['base'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$snapshot = $this->service()->snapshot($base, $at, $symbols, $windowMinutes);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/rate' && $method === 'GET') {
$from = $this->stringOrNull($_GET['from'] ?? null);
$to = $this->stringOrNull($_GET['to'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$rate = $this->service()->findRate($from, $to, $at, $windowMinutes);
$this->respond(['data' => $rate]);
}
if ($path === 'v1/history' && $method === 'GET') {
$from = $this->stringOrNull($_GET['from'] ?? null);
$to = $this->stringOrNull($_GET['to'] ?? null);
$fromAt = $this->stringOrNull($_GET['from_at'] ?? null);
$toAt = $this->stringOrNull($_GET['to_at'] ?? null);
$limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200)));
$history = $this->service()->history((string) $from, (string) $to, $fromAt, $toAt, $limit);
$this->respond(['data' => $history]);
}
if ($path === 'v1/refresh' && $method === 'POST') {
$input = $this->input();
$base = $this->stringOrNull($input['base'] ?? null);
$force = !empty($input['force']);
$maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null)
? (int) $input['max_age_minutes']
: (is_numeric($input['max_age_hours'] ?? null) ? (int) round(((float) $input['max_age_hours']) * 60) : null);
$result = $force
? $this->service()->refreshLatestRates(null, $base, (string) ($input['trigger_source'] ?? 'manual'))
: $this->service()->autoRefreshLatestRates($base, null, $maxAgeMinutes, (string) ($input['trigger_source'] ?? 'api'));
$this->respond(['data' => $result], 201);
}
if ($path === 'v1/probe' && $method === 'GET') {
$base = $this->stringOrNull($_GET['base'] ?? null);
$this->respond(['data' => $this->service()->probeLatestRates($base)]);
}
if ($path === 'v1/currency-catalog/probe' && $method === 'GET') {
$this->respond(['data' => $this->service()->probeCurrencyCatalog()]);
}
if ($path === 'v1/currency-catalog/sync' && $method === 'POST') {
$result = $this->service()->refreshCurrencyCatalog();
$current = $this->settingsStore->load();
$catalog = [];
foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) {
if (!is_array($item)) {
continue;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$catalog[$code] = ['code' => $code, 'name' => $name];
}
foreach ([
(string) ($current['default_base_currency'] ?? ''),
(string) ($current['display_base_currency'] ?? ''),
...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])),
] as $code) {
$code = strtoupper(trim((string) $code));
if ($code !== '' && !isset($catalog[$code])) {
$catalog[$code] = ['code' => $code, 'name' => $code];
}
}
ksort($catalog);
$settings = $this->settingsStore->save([
'currency_catalog' => array_values($catalog),
'currency_catalog_synced_at' => gmdate(DATE_ATOM),
]);
$this->reloadService();
$this->respond(['data' => [
'sync' => $result,
'settings' => $settings,
]], 201);
}
if ($path === 'v1/settings' && $method === 'GET') {
$this->respond(['data' => $this->settingsStore->load()]);
}
if ($path === 'v1/settings' && $method === 'PUT') {
$payload = $this->normalizeSettingsPayload($this->input());
$settings = $this->settingsStore->save($payload);
$this->reloadService();
$this->respond(['data' => $settings]);
}
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
} catch (\Throwable $exception) {
$this->respond([
'error' => 'FX-API Fehler.',
'context' => ['message' => $exception->getMessage()],
], $exception instanceof RuntimeException ? 500 : 500);
}
}
private function service(): FxRatesService
{
if ($this->service instanceof FxRatesService) {
return $this->service;
}
$repository = new FxRatesRepository(
ConnectionFactory::make($this->settingsStore),
$this->moduleConfig->tablePrefix()
);
$repository->ensureSchema();
return $this->service = new FxRatesService($repository, $this->settingsStore->load());
}
private function reloadService(): void
{
$this->service = null;
}
private function respond(array $payload, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
private function input(): array
{
$raw = file_get_contents('php://input');
$decoded = json_decode((string) $raw, true);
return is_array($decoded) ? $decoded : [];
}
private function parseCsv(mixed $value): ?array
{
if (is_array($value)) {
$items = $value;
} else {
$value = trim((string) $value);
if ($value === '') {
return null;
}
$items = explode(',', $value);
}
$result = [];
foreach ($items as $item) {
$item = strtoupper(trim((string) $item));
if ($item !== '') {
$result[] = $item;
}
}
$result = array_values(array_unique($result));
return $result !== [] ? $result : null;
}
private function stringOrNull(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function normalizeSettingsPayload(array $input): array
{
$payload = [];
foreach (['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'] as $key) {
if (array_key_exists($key, $input)) {
$payload[$key] = $input[$key];
}
}
if (array_key_exists('timeout_sec', $input)) {
$payload['timeout_sec'] = (int) $input['timeout_sec'];
}
if (array_key_exists('refresh_max_age_hours', $input)) {
$payload['refresh_max_age_hours'] = (float) $input['refresh_max_age_hours'];
}
if (array_key_exists('refresh_max_age_minutes', $input) && !array_key_exists('refresh_max_age_hours', $input)) {
$payload['refresh_max_age_hours'] = ((float) $input['refresh_max_age_minutes']) / 60;
}
if (array_key_exists('preferred_currencies', $input)) {
$payload['preferred_currencies'] = $input['preferred_currencies'];
}
if (array_key_exists('currency_catalog', $input)) {
$payload['currency_catalog'] = $input['currency_catalog'];
}
if (array_key_exists('currency_catalog_synced_at', $input)) {
$payload['currency_catalog_synced_at'] = $input['currency_catalog_synced_at'];
}
if (array_key_exists('use_separate_db', $input)) {
$payload['use_separate_db'] = !empty($input['use_separate_db']);
}
if (array_key_exists('db', $input) && is_array($input['db'])) {
$payload['db'] = $input['db'];
}
return $payload;
}
private function endpointCatalog(): array
{
return [
'module' => 'fx-rates',
'version' => 'v1',
'languages' => ['de', 'en'],
'endpoints' => [
['path' => '/api/fx-rates/v1/endpoints', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/latest', 'method' => 'GET', 'params' => ['base', 'symbols']],
['path' => '/api/fx-rates/v1/fetch', 'method' => 'GET', 'params' => ['fetch_id', 'base', 'symbols']],
['path' => '/api/fx-rates/v1/nearest', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']],
['path' => '/api/fx-rates/v1/snapshot', 'method' => 'GET', 'params' => ['at', 'base', 'symbols', 'window_minutes']],
['path' => '/api/fx-rates/v1/rate', 'method' => 'GET', 'params' => ['from', 'to', 'at', 'window_minutes']],
['path' => '/api/fx-rates/v1/history', 'method' => 'GET', 'params' => ['from', 'to', 'from_at', 'to_at', 'limit']],
['path' => '/api/fx-rates/v1/refresh', 'method' => 'POST', 'body' => ['base', 'force', 'max_age_minutes', 'max_age_hours']],
['path' => '/api/fx-rates/v1/status', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/recent-fetches', 'method' => 'GET', 'params' => ['limit']],
['path' => '/api/fx-rates/v1/probe', 'method' => 'GET', 'params' => ['base']],
['path' => '/api/fx-rates/v1/currency-catalog/probe', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/currency-catalog/sync', 'method' => 'POST'],
['path' => '/api/fx-rates/v1/settings', 'method' => 'GET'],
['path' => '/api/fx-rates/v1/settings', 'method' => 'PUT'],
],
];
}
}

View File

@@ -0,0 +1,991 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Domain;
use DateTimeImmutable;
use DateTimeZone;
use Modules\FxRates\Infrastructure\FxRatesRepository;
final class FxRatesService
{
private array $memoryCache = [];
public function __construct(
private FxRatesRepository $repository,
private array $settings = []
) {
}
public function latestStatus(): ?array
{
return $this->localizeFetch($this->repository->getLatestFetch(null));
}
public function latestStatuses(): array
{
return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listLatestFetches());
}
public function recentFetches(int $limit = 20): array
{
return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listRecentFetches($limit));
}
public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
if ($base === '') {
return null;
}
if ($at === null || trim($at) === '') {
$latest = $this->repository->getLatestFetch(null);
if ($latest === null) {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) $latest['id'], null);
if ($snapshot === null) {
return null;
}
return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $base, $symbols));
}
$atUtc = $this->normalizeTimestamp($at);
if ($atUtc === null) {
return null;
}
$nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes);
if ($nearest === null) {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols);
if ($snapshot === null) {
return null;
}
$rebased = $this->rebaseSnapshot($snapshot, $base, $symbols);
if ($rebased === null) {
return null;
}
return $this->localizeSnapshot($rebased + [
'requested_at' => $atUtc,
'distance_seconds' => $nearest['distance_seconds'] ?? null,
]);
}
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
{
if ($fetchId <= 0) {
return null;
}
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
if ($requestedBase === '') {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId($fetchId, null);
if ($snapshot === null) {
return null;
}
return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols));
}
public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$timestamp = $this->normalizeTimestamp($at);
if ($timestamp === null) {
return null;
}
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
if ($requestedBase === '') {
return null;
}
$nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes);
if ($nearest === null) {
return null;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null);
if ($snapshot === null) {
return null;
}
$rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols);
if ($rebased === null) {
return null;
}
return $this->localizeSnapshot($rebased + [
'requested_at' => $timestamp,
'distance_seconds' => $nearest['distance_seconds'] ?? null,
]);
}
public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array
{
$from = $this->normalizeCurrency($fromCurrency);
$to = $this->normalizeCurrency($toCurrency);
if ($from === '' || $to === '') {
return null;
}
if ($from === $to) {
return $this->localizeRateResult([
'base_currency' => $from,
'target_currency' => $to,
'rate' => 1.0,
'provider' => 'identity',
'fetched_at' => $at ? $this->normalizeTimestamp($at) : null,
'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'),
'is_exact_pair' => true,
]);
}
$cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]);
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
$candidates = array_values(array_unique(array_filter([
$this->defaultBaseCurrency(),
'EUR',
'USD',
$from,
$to,
], static fn (?string $value): bool => is_string($value) && trim($value) !== '')));
foreach ($candidates as $snapshotBase) {
$snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes);
if (!is_array($snapshot)) {
continue;
}
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to);
if ($resolved !== null) {
return $this->memoryCache[$cacheKey] = $this->localizeRateResult($resolved);
}
}
return $this->memoryCache[$cacheKey] = null;
}
public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float
{
if ($amount === null) {
return null;
}
$rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
if (!is_numeric($rate['rate'] ?? null)) {
return null;
}
return $amount * (float) $rate['rate'];
}
public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array
{
$requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
$requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $requestedBase);
$payload = $this->fetchLatestPayload($requestedBase, $requestedCurrencies);
$base = $this->normalizeCurrency((string) ($payload['base'] ?? $requestedBase));
if ($base === '') {
$base = $requestedBase !== '' ? $requestedBase : 'USD';
}
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$saved = $this->repository->saveFetch(
$base,
$this->provider(),
$rateDate,
$rates,
gmdate('Y-m-d H:i:s'),
$triggerSource
);
return [
'base' => $base,
'requested_base' => $requestedBase,
'rate_date' => $rateDate,
'updated_count' => count($saved['rates'] ?? []),
'rates' => $saved['rates'] ?? [],
'fetch_id' => isset($saved['fetch']['id']) ? (int) $saved['fetch']['id'] : null,
'fetch' => $this->localizeFetch(is_array($saved['fetch'] ?? null) ? $saved['fetch'] : null),
];
}
public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array
{
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
$requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $base);
$latest = $this->repository->getLatestFetch($base);
$maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600);
$fetchedAt = is_array($latest) ? $this->parseStoredUtcTimestamp((string) ($latest['fetched_at'] ?? '')) : null;
if (
$fetchedAt !== null
&& (time() - $fetchedAt) <= $maxAgeSeconds
&& $this->latestFetchCoversCurrencies($latest, $requestedCurrencies)
) {
return [
'base' => $base,
'rate_date' => $latest['rate_date'] ?? null,
'updated_count' => 0,
'rates' => [],
'fetch_id' => isset($latest['id']) ? (int) $latest['id'] : null,
'fetch' => $this->localizeFetch($latest),
'reused' => true,
];
}
$result = $this->refreshLatestRates($requestedCurrencies, $base, $triggerSource);
$result['reused'] = false;
return $result;
}
public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null, string $triggerSource = 'api'): array
{
$minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes();
$hours = max(1, $minutes) / 60;
return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies, $triggerSource);
}
public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
{
$fromCurrency = $this->normalizeCurrency($fromCurrency);
$toCurrency = $this->normalizeCurrency($toCurrency);
if ($fromCurrency === '' || $toCurrency === '') {
return [];
}
if ($fromCurrency === $toCurrency) {
return [];
}
return $this->crossHistory($fromCurrency, $toCurrency, $from, $to, $limit);
}
public function refreshCurrencyCatalog(): array
{
$payload = $this->fetchCurrenciesPayload();
$currencies = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
$items = [];
foreach ($currencies as $code => $name) {
$code = $this->normalizeCurrency((string) $code);
$name = trim((string) $name);
if ($code === '' || $name === '') {
continue;
}
$items[] = [
'code' => $code,
'name' => $name,
];
}
return [
'synced_count' => count($items),
'currencies' => $items,
];
}
public function probeCurrencyCatalog(): array
{
$payload = $this->fetchCurrenciesPayload();
return [
'ok' => !empty($payload['currencies']),
'provider' => $this->provider(),
'currencies_count' => is_array($payload['currencies'] ?? null) ? count($payload['currencies']) : 0,
];
}
public function probeLatestRates(?string $baseCurrency = null): array
{
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
$payload = $this->fetchLatestPayload($base, null);
return [
'ok' => !empty($payload['rates']),
'provider' => $this->provider(),
'base' => $base,
'rate_count' => is_array($payload['rates'] ?? null) ? count($payload['rates']) : 0,
'date' => $payload['date'] ?? null,
];
}
private function resolveRateFromSnapshot(array $snapshot, array $rates, string $from, string $to): ?array
{
$base = strtoupper((string) ($snapshot['base_currency'] ?? ''));
$rate = null;
$isExactPair = false;
if ($base === $from && is_numeric($rates[$to] ?? null)) {
$rate = (float) $rates[$to];
$isExactPair = true;
} elseif ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
$rate = 1 / (float) $rates[$from];
$isExactPair = true;
} elseif (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
$rate = (float) $rates[$to] / (float) $rates[$from];
}
if ($rate === null || $rate <= 0) {
return null;
}
return [
'base_currency' => $from,
'target_currency' => $to,
'rate' => $rate,
'provider' => $snapshot['provider'] ?? null,
'fetched_at' => $snapshot['fetched_at'] ?? null,
'rate_date' => $snapshot['rate_date'] ?? null,
'snapshot_base_currency' => $base,
'distance_seconds' => $snapshot['distance_seconds'] ?? null,
'requested_at' => $snapshot['requested_at'] ?? null,
'is_exact_pair' => $isExactPair,
];
}
private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array
{
$request = $this->buildLatestRequest($baseCurrency, $currencies);
if ($request === null) {
throw new \RuntimeException('FX-URL oder API-Key fehlt.');
}
$payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'FX-Kurse konnten nicht geladen werden.');
if ($this->usesApiVersion('v3')) {
return $this->normalizeCurrencyApiComLatestPayload($payload, $baseCurrency, $currencies);
}
$resolvedBase = $baseCurrency;
$rates = [];
if ($this->provider() === 'currencyapi') {
$resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? ($payload['query']['base_currency'] ?? 'USD')));
if ($resolvedBase === '') {
$resolvedBase = 'USD';
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
}
foreach ($payload['rates'] as $code => $rate) {
$code = $this->normalizeCurrency((string) $code);
if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) {
continue;
}
$rates[$code] = (float) $rate;
}
} else {
$resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? $baseCurrency));
if ($resolvedBase === '') {
$resolvedBase = $baseCurrency;
}
$rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
foreach ($rawRates as $code => $rate) {
$code = $this->normalizeCurrency((string) $code);
if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) {
continue;
}
$rates[$code] = (float) $rate;
}
}
if (is_array($currencies) && $currencies !== []) {
$wanted = [];
foreach ($currencies as $currency) {
$currency = $this->normalizeCurrency((string) $currency);
if ($currency !== '' && isset($rates[$currency])) {
$wanted[$currency] = $rates[$currency];
}
}
$rates = $wanted;
}
return [
'base' => $resolvedBase,
'date' => $payload['updated'] ?? $payload['date'] ?? null,
'rates' => $rates,
];
}
private function fetchCurrenciesPayload(): array
{
$request = $this->buildCurrenciesRequest();
if ($request === null) {
throw new \RuntimeException('FX-API-Key fehlt.');
}
$payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'Waehrungskatalog konnte nicht geladen werden.');
if ($this->usesApiVersion('v3')) {
return $this->normalizeCurrencyApiComCurrenciesPayload($payload);
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
return $payload;
}
private function buildLatestRequest(string $baseCurrency, ?array $currencies = null): ?array
{
$apiKey = $this->apiKey();
if ($this->usesApiVersion('v3')) {
if ($apiKey === '') {
return null;
}
$query = [];
$normalizedCurrencies = [];
foreach ($currencies ?? [] as $currency) {
$currency = $this->normalizeCurrency((string) $currency);
if ($currency !== '' && $currency !== $baseCurrency) {
$normalizedCurrencies[] = $currency;
}
}
if ($normalizedCurrencies !== []) {
$query[] = 'currencies=' . rawurlencode(implode(',', array_values(array_unique($normalizedCurrencies))));
}
return [
'url' => $this->apiUrl() . '/v3/latest?' . implode('&', $query),
'headers' => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
];
}
if ($this->provider() === 'currencyapi') {
if ($apiKey === '') {
return null;
}
return [
'url' => sprintf('%s/api/v2/rates?output=json&key=%s', $this->apiUrl(), rawurlencode($apiKey)),
'headers' => ['Accept: application/json'],
];
}
return [
'url' => sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)),
'headers' => ['Accept: application/json'],
];
}
private function buildCurrenciesRequest(): ?array
{
$apiKey = $this->apiKey();
if ($apiKey === '') {
return null;
}
if ($this->usesApiVersion('v3')) {
return [
'url' => $this->currenciesApiUrl() . '/v3/currencies',
'headers' => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
];
}
return [
'url' => sprintf('%s/api/v2/currencies?output=json&key=%s', $this->currenciesApiUrl(), rawurlencode($apiKey)),
'headers' => ['Accept: application/json'],
];
}
private function requestJson(string $url, array $headers, string $fallbackError): array
{
if (!function_exists('curl_init')) {
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
CURLOPT_HTTPHEADER => $headers !== [] ? $headers : ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
$payload = is_string($response) ? json_decode($response, true) : null;
throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], $fallbackError));
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
}
return $payload;
}
private function normalizeCurrencyApiComLatestPayload(array $payload, string $baseCurrency, ?array $currencies = null): array
{
$rawRates = is_array($payload['data'] ?? null) ? $payload['data'] : null;
if ($rawRates === null) {
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
}
$resolvedBase = $this->normalizeCurrency((string) ($payload['meta']['base_currency_code'] ?? $payload['base'] ?? $baseCurrency));
if ($resolvedBase === '') {
$resolvedBase = $baseCurrency;
}
$filter = [];
foreach ($currencies ?? [] as $currency) {
$currency = $this->normalizeCurrency((string) $currency);
if ($currency !== '' && $currency !== $resolvedBase) {
$filter[$currency] = true;
}
}
$rates = [];
foreach ($rawRates as $code => $rateData) {
$code = $this->normalizeCurrency((string) $code);
if ($code === '' || $code === $resolvedBase) {
continue;
}
if ($filter !== [] && !isset($filter[$code])) {
continue;
}
$value = is_array($rateData) ? ($rateData['value'] ?? null) : null;
if (!is_numeric($value)) {
continue;
}
$rates[$code] = (float) $value;
}
return [
'base' => $resolvedBase,
'date' => $payload['meta']['last_updated_at'] ?? null,
'rates' => $rates,
];
}
private function rebaseSnapshot(array $snapshot, string $requestedBase, ?array $symbols = null): ?array
{
$snapshotBase = $this->normalizeCurrency((string) ($snapshot['base_currency'] ?? ''));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
if ($snapshotBase === '') {
return null;
}
if ($requestedBase === '' || $requestedBase === $snapshotBase) {
$filteredRates = $this->filterRates($rates, $symbols);
if ($this->symbolsContain($symbols, $requestedBase)) {
$filteredRates = [$requestedBase => 1.0] + $filteredRates;
}
return $snapshot + [
'base_currency' => $snapshotBase,
'rates' => $filteredRates,
];
}
$baseRate = $rates[$requestedBase] ?? null;
if (!is_numeric($baseRate) || (float) $baseRate <= 0) {
return null;
}
$rebasedRates = [];
foreach ($rates as $code => $rate) {
$code = $this->normalizeCurrency((string) $code);
if ($code === '' || $code === $requestedBase || !is_numeric($rate)) {
continue;
}
$rebasedRates[$code] = (float) $rate / (float) $baseRate;
}
if ($snapshotBase !== '' && $snapshotBase !== $requestedBase) {
$rebasedRates[$snapshotBase] = 1 / (float) $baseRate;
}
$filteredRates = $this->filterRates($rebasedRates, $symbols);
if ($this->symbolsContain($symbols, $requestedBase)) {
$filteredRates = [$requestedBase => 1.0] + $filteredRates;
}
return $snapshot + [
'base_currency' => $requestedBase,
'rates' => $filteredRates,
'snapshot_base_currency' => $snapshotBase,
];
}
private function filterRates(array $rates, ?array $symbols = null): array
{
if (!is_array($symbols) || $symbols === []) {
ksort($rates);
return $rates;
}
$filtered = [];
foreach ($symbols as $symbol) {
$symbol = $this->normalizeCurrency((string) $symbol);
if ($symbol !== '' && isset($rates[$symbol])) {
$filtered[$symbol] = (float) $rates[$symbol];
}
}
ksort($filtered);
return $filtered;
}
private function symbolsContain(?array $symbols, string $currency): bool
{
if (!is_array($symbols) || $symbols === []) {
return false;
}
foreach ($symbols as $symbol) {
if ($this->normalizeCurrency((string) $symbol) === $currency) {
return true;
}
}
return false;
}
private function crossHistory(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
{
$fromAt = $this->normalizeTimestamp($from);
$toAt = $this->normalizeTimestamp($to);
$candidates = $this->repository->listRecentFetches(max($limit * 4, 50));
$result = [];
foreach ($candidates as $fetch) {
$fetchedAt = (string) ($fetch['fetched_at'] ?? '');
if ($fetchedAt === '') {
continue;
}
if ($fromAt !== null && strcmp($fetchedAt, $fromAt) < 0) {
continue;
}
if ($toAt !== null && strcmp($fetchedAt, $toAt) > 0) {
continue;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) ($fetch['id'] ?? 0), [$fromCurrency, $toCurrency]);
if ($snapshot === null) {
continue;
}
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $fromCurrency, $toCurrency);
if ($resolved === null) {
continue;
}
$result[] = $this->localizeRateResult($resolved + [
'fetch_id' => $fetch['id'] ?? null,
]);
if (count($result) >= $limit) {
break;
}
}
return $result;
}
private function localizeFetch(?array $fetch): ?array
{
if (!is_array($fetch)) {
return null;
}
$fetch['fetched_at_display'] = $this->formatDisplayTimestamp($fetch['fetched_at'] ?? null);
$fetch['created_at_display'] = $this->formatDisplayTimestamp($fetch['created_at'] ?? null);
$fetch['trigger_source_label'] = $this->triggerSourceLabel((string) ($fetch['trigger_source'] ?? 'manual'));
return $fetch;
}
private function localizeSnapshot(?array $snapshot): ?array
{
if (!is_array($snapshot)) {
return null;
}
$snapshot['fetched_at_display'] = $this->formatDisplayTimestamp($snapshot['fetched_at'] ?? null);
if (array_key_exists('requested_at', $snapshot)) {
$snapshot['requested_at_display'] = $this->formatDisplayTimestamp($snapshot['requested_at']);
}
return $snapshot;
}
private function localizeRateResult(array $rate): array
{
$rate['fetched_at_display'] = $this->formatDisplayTimestamp($rate['fetched_at'] ?? null);
if (array_key_exists('requested_at', $rate)) {
$rate['requested_at_display'] = $this->formatDisplayTimestamp($rate['requested_at']);
}
return $rate;
}
private function formatDisplayTimestamp(mixed $value): string
{
$raw = trim((string) $value);
if ($raw === '') {
return '';
}
try {
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC'));
if (!$date instanceof DateTimeImmutable) {
$date = new DateTimeImmutable($raw, new DateTimeZone('UTC'));
}
return $date->setTimezone($this->displayTimezone())->format('d.m.Y H:i:s');
} catch (\Throwable) {
return $raw;
}
}
private function normalizeCurrencyApiComCurrenciesPayload(array $payload): array
{
$rawCurrencies = is_array($payload['data'] ?? null) ? $payload['data'] : null;
if ($rawCurrencies === null) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
$currencies = [];
foreach ($rawCurrencies as $code => $currencyData) {
$normalizedCode = $this->normalizeCurrency((string) $code);
if ($normalizedCode === '') {
continue;
}
$name = '';
if (is_array($currencyData)) {
$name = trim((string) ($currencyData['name'] ?? $currencyData['name_plural'] ?? $currencyData['code'] ?? ''));
}
if ($name === '') {
continue;
}
$currencies[$normalizedCode] = $name;
}
return [
'valid' => true,
'currencies' => $currencies,
];
}
private function extractProviderError(array $payload, string $fallback): string
{
$error = $payload['error'] ?? null;
if (is_array($error)) {
foreach (['message', 'info', 'code'] as $field) {
$value = $error[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
$errors = $payload['errors'] ?? null;
if (is_array($errors)) {
foreach ($errors as $entry) {
if (is_string($entry) && trim($entry) !== '') {
return trim($entry);
}
if (is_array($entry)) {
foreach (['message', 'detail', 'title'] as $field) {
$value = $entry[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
}
}
foreach (['error', 'message', 'msg'] as $field) {
$value = $payload[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
return $fallback;
}
private function normalizeCurrency(?string $currency): string
{
return strtoupper(trim((string) $currency));
}
private function normalizeRequestedCurrencies(?array $currencies, string $baseCurrency): ?array
{
if (!is_array($currencies)) {
return null;
}
$base = $this->normalizeCurrency($baseCurrency);
$normalized = array_values(array_unique(array_filter(array_map(
fn (mixed $currency): string => $this->normalizeCurrency((string) $currency),
$currencies
), fn (string $currency): bool => $currency !== '' && $currency !== $base)));
return $normalized === [] ? null : $normalized;
}
private function latestFetchCoversCurrencies(?array $latestFetch, ?array $currencies): bool
{
if (!is_array($latestFetch) || !is_numeric($latestFetch['id'] ?? null) || !is_array($currencies) || $currencies === []) {
return true;
}
$snapshot = $this->repository->getSnapshotByFetchId((int) $latestFetch['id'], $currencies);
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
foreach ($currencies as $currency) {
if (!array_key_exists($currency, $rates)) {
return false;
}
}
return true;
}
private function normalizeTimestamp(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
try {
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $value) === 1) {
$date = new DateTimeImmutable(str_replace(' ', 'T', $value), new DateTimeZone('UTC'));
} else {
$date = new DateTimeImmutable($value);
}
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return null;
}
}
private function parseStoredUtcTimestamp(string $value): ?int
{
$normalized = $this->normalizeTimestamp($value);
if ($normalized === null) {
return null;
}
try {
return (new DateTimeImmutable($normalized, new DateTimeZone('UTC')))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
private function normalizeRateDate(mixed $value): string
{
if (is_string($value) && trim($value) !== '') {
$timestamp = strtotime($value);
if ($timestamp !== false) {
return gmdate('Y-m-d', $timestamp);
}
}
if (is_int($value) || is_float($value)) {
return gmdate('Y-m-d', (int) $value);
}
return gmdate('Y-m-d');
}
private function provider(): string
{
$provider = strtolower(trim((string) ($this->settings['provider'] ?? 'currencyapi')));
return $provider !== '' ? $provider : 'currencyapi';
}
private function apiUrl(): string
{
return rtrim((string) ($this->settings['api_url'] ?? 'https://currencyapi.net'), '/');
}
private function currenciesApiUrl(): string
{
return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/');
}
private function apiVersion(): string
{
$version = strtolower(trim((string) ($this->settings['api_version'] ?? 'v2')));
return in_array($version, ['v2', 'v3'], true) ? $version : 'v2';
}
private function usesApiVersion(string $version): bool
{
return $this->apiVersion() === strtolower(trim($version));
}
private function apiKey(): string
{
return trim((string) ($this->settings['api_key'] ?? ''));
}
private function timeoutSeconds(): int
{
return max(2, (int) ($this->settings['timeout_sec'] ?? 10));
}
private function refreshMaxAgeMinutes(): int
{
return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 360));
}
private function defaultBaseCurrency(): string
{
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
}
private function displayTimezone(): DateTimeZone
{
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? nexus_display_timezone_name()));
try {
return new DateTimeZone($timezone);
} catch (\Throwable) {
return new DateTimeZone(nexus_display_timezone_name());
}
}
private function triggerSourceLabel(string $source): string
{
return match (strtolower(trim($source))) {
'cron' => 'Cron',
'api' => 'API',
'widget' => 'Widget',
default => 'Manuell',
};
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
use App\PdoFactory;
use RuntimeException;
use PDO;
final class ConnectionFactory
{
public static function make(SettingsStore $settingsStore): PDO
{
$settings = $settingsStore->load();
$useSeparateDb = !empty($settings['use_separate_db']);
if ($useSeparateDb) {
$dbConfig = is_array($settings['db'] ?? null) ? $settings['db'] : [];
if ($dbConfig === []) {
throw new RuntimeException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.');
}
self::assertSupportedDriver($dbConfig);
return PdoFactory::createFromArray($dbConfig);
}
$dbConfig = app()->config()->dbConfig;
if ($dbConfig === []) {
throw new RuntimeException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.');
}
self::assertSupportedDriver($dbConfig);
return PdoFactory::createFromArray($dbConfig);
}
private static function assertSupportedDriver(array $dbConfig): void
{
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql', 'sqlite'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:') && !str_starts_with($driver, 'sqlite:')) {
throw new RuntimeException('FX-Rates unterstuetzt aktuell MySQL/MariaDB, PostgreSQL und SQLite.');
}
}
}

View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
use PDO;
final class FxRatesRepository
{
private string $driver;
public function __construct(
private PDO $pdo,
private string $tablePrefix = 'fxrate_'
) {
$this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
}
public function ensureSchema(): void
{
$fetchTable = $this->table('fetches');
$rateTable = $this->table('rates');
if ($this->driver === 'pgsql') {
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id SERIAL PRIMARY KEY,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
id SERIAL PRIMARY KEY,
fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE,
currency_code VARCHAR(10) NOT NULL,
current_value NUMERIC(20,10) NOT NULL
)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
return;
}
if ($this->driver === 'mysql') {
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id),
KEY {$fetchTable}_rate_date_idx (rate_date)
)");
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
fetch_id INTEGER NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY {$rateTable}_fetch_idx (fetch_id),
KEY {$rateTable}_currency_idx (currency_code)
)");
return;
}
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fetch_id INTEGER NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL
)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
}
public function getLatestFetch(?string $baseCurrency = null): ?array
{
$sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches');
$params = [];
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
$sql .= ' WHERE base_currency = :base_currency';
$params['base_currency'] = strtoupper(trim($baseCurrency));
}
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
public function listLatestFetches(): array
{
$stmt = $this->pdo->query(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
ORDER BY fetched_at DESC, id DESC'
);
$latestByBase = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$base = strtoupper(trim((string) ($row['base_currency'] ?? '')));
if ($base === '' || isset($latestByBase[$base])) {
continue;
}
$latestByBase[$base] = $this->normalizeFetch($row);
}
ksort($latestByBase);
return array_values($latestByBase);
}
public function listRecentFetches(int $limit = 20): array
{
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
ORDER BY fetched_at DESC, id DESC
LIMIT :limit'
);
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(
fn (array $row): array => $this->normalizeFetch($row),
$stmt->fetchAll(PDO::FETCH_ASSOC) ?: []
);
}
public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array
{
$fetch = $this->getFetchById($fetchId);
if ($fetch === null) {
return null;
}
return $fetch + [
'rates' => $this->ratesForFetch($fetchId, $symbols),
];
}
public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
{
$targetTs = strtotime($timestamp);
if ($targetTs === false) {
return null;
}
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes);
}
$candidates = [];
foreach (['<=', '>='] as $operator) {
$order = $operator === '<=' ? 'DESC' : 'ASC';
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE fetched_at ' . $operator . ' :target_at
ORDER BY fetched_at ' . $order . ', id ' . $order . '
LIMIT 1'
);
$stmt->execute(['target_at' => $timestamp]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
$candidate = $this->normalizeFetch($row);
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
if ($candidateTs !== false) {
$candidate['distance_seconds'] = abs($candidateTs - $targetTs);
$candidates[] = $candidate;
}
}
}
if ($candidates === []) {
return null;
}
usort($candidates, static fn (array $left, array $right): int => ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX)));
$selected = $candidates[0];
if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) {
return null;
}
return $selected;
}
public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));
if ($baseCurrency === '') {
return null;
}
$before = $this->findNeighborFetch($baseCurrency, $timestamp, '<=');
$after = $this->findNeighborFetch($baseCurrency, $timestamp, '>=');
$targetTs = strtotime($timestamp);
if ($targetTs === false) {
return null;
}
$selected = null;
$selectedDiff = null;
foreach ([$before, $after] as $candidate) {
if (!is_array($candidate)) {
continue;
}
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
if ($candidateTs === false) {
continue;
}
$diffSeconds = abs($candidateTs - $targetTs);
if ($selected === null || $diffSeconds < (int) $selectedDiff) {
$selected = $candidate;
$selectedDiff = $diffSeconds;
}
}
if ($selected === null) {
return null;
}
if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) {
return null;
}
return $selected + ['distance_seconds' => $selectedDiff];
}
public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
{
$sql = 'SELECT
r.id,
f.id AS fetch_id,
f.base_currency,
r.currency_code AS target_currency,
r.current_value AS rate,
f.rate_date,
f.provider,
f.fetched_at
FROM ' . $this->table('rates') . ' r
INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id
WHERE f.base_currency = :base_currency
AND r.currency_code = :target_currency';
$params = [
'base_currency' => strtoupper(trim($baseCurrency)),
'target_currency' => strtoupper(trim($targetCurrency)),
];
if ($from !== null && trim($from) !== '') {
$sql .= ' AND f.fetched_at >= :from_at';
$params['from_at'] = $from;
}
if ($to !== null && trim($to) !== '') {
$sql .= ' AND f.fetched_at <= :to_at';
$params['to_at'] = $to;
}
$sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value);
}
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
}
public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
$fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s');
$triggerSource = $this->normalizeTriggerSource($triggerSource);
$normalizedRates = [];
foreach ($rates as $currencyCode => $rate) {
$currencyCode = strtoupper(trim((string) $currencyCode));
if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) {
continue;
}
$normalizedRates[$currencyCode] = (float) $rate;
}
$startedTransaction = false;
if (!$this->pdo->inTransaction()) {
$this->pdo->beginTransaction();
$startedTransaction = true;
}
try {
if ($this->driver === 'pgsql') {
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fetches') . ' (
provider, trigger_source, base_currency, rate_date, fetched_at
) VALUES (
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
)
RETURNING *'
);
$fetchStmt->execute([
'provider' => $provider,
'trigger_source' => $triggerSource,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []);
} else {
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fetches') . ' (
provider, trigger_source, base_currency, rate_date, fetched_at
) VALUES (
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
)'
);
$fetchStmt->execute([
'provider' => $provider,
'trigger_source' => $triggerSource,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? [];
}
$savedRates = [];
if ($normalizedRates !== []) {
$placeholders = [];
$params = ['fetch_id' => (int) ($fetch['id'] ?? 0)];
$index = 0;
foreach ($normalizedRates as $currencyCode => $rate) {
$codeKey = 'currency_code_' . $index;
$valueKey = 'current_value_' . $index;
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
$params[$codeKey] = $currencyCode;
$params[$valueKey] = $rate;
$savedRates[] = [
'fetch_id' => $fetch['id'] ?? null,
'base_currency' => $baseCurrency,
'target_currency' => $currencyCode,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $provider,
'fetched_at' => $fetchedAt,
];
$index++;
}
$insert = $this->pdo->prepare(
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
);
$insert->execute($params);
}
if ($startedTransaction) {
$this->pdo->commit();
}
return [
'fetch' => $fetch,
'rates' => $savedRates,
];
} catch (\Throwable $exception) {
if ($startedTransaction && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$fetchedAt = trim($fetchedAt);
if ($baseCurrency === '' || $fetchedAt === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE base_currency = :base_currency
AND fetched_at = :fetched_at
ORDER BY id ASC
LIMIT 1'
);
$stmt->execute([
'base_currency' => $baseCurrency,
'fetched_at' => $fetchedAt,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function getFetchById(int $fetchId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE id = :id
LIMIT 1'
);
$stmt->execute(['id' => $fetchId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array
{
$order = $operator === '<=' ? 'DESC' : 'ASC';
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE base_currency = :base_currency
AND fetched_at ' . $operator . ' :target_at
ORDER BY fetched_at ' . $order . ', id ' . $order . '
LIMIT 1'
);
$stmt->execute([
'base_currency' => $baseCurrency,
'target_at' => $timestamp,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function ratesForFetch(int $fetchId, ?array $symbols = null): array
{
$sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id';
$params = ['fetch_id' => $fetchId];
$normalizedSymbols = [];
if (is_array($symbols)) {
foreach ($symbols as $symbol) {
$symbol = strtoupper(trim((string) $symbol));
if ($symbol !== '') {
$normalizedSymbols[] = $symbol;
}
}
$normalizedSymbols = array_values(array_unique($normalizedSymbols));
}
if ($normalizedSymbols !== []) {
$placeholders = [];
foreach ($normalizedSymbols as $index => $symbol) {
$key = 'symbol_' . $index;
$placeholders[] = ':' . $key;
$params[$key] = $symbol;
}
$sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')';
}
$sql .= ' ORDER BY currency_code ASC';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rates = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$code = strtoupper(trim((string) ($row['currency_code'] ?? '')));
$rate = $row['current_value'] ?? null;
if ($code === '' || !is_numeric($rate)) {
continue;
}
$rates[$code] = (float) $rate;
}
return $rates;
}
private function normalizeFetch(array $row): array
{
return [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'provider' => (string) ($row['provider'] ?? ''),
'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'),
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
'rate_date' => (string) ($row['rate_date'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
private function ensureColumn(string $table, string $column, string $alterSql): void
{
try {
$stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1');
if ($stmt instanceof \PDOStatement) {
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) {
return;
}
}
} catch (\Throwable) {
}
try {
$this->pdo->exec($alterSql);
} catch (\Throwable) {
}
}
private function normalizeTriggerSource(string $source): string
{
$source = strtolower(trim($source));
return match ($source) {
'cron', 'manual', 'api', 'migration', 'widget' => $source,
default => 'manual',
};
}
private function normalizeRate(array $row): array
{
return [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
'rate_date' => (string) ($row['rate_date'] ?? ''),
'provider' => (string) ($row['provider'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
];
}
private function table(string $logicalName): string
{
return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
final class ModuleConfig
{
public function __construct(
private array $config
) {
}
public static function load(string $moduleBasePath): self
{
$config = require $moduleBasePath . '/config/module.php';
return new self(is_array($config) ? $config : []);
}
public function all(): array
{
return $this->config;
}
public function useProjectDatabase(): bool
{
return (bool) ($this->config['use_project_database'] ?? true);
}
public function tablePrefix(): string
{
return (string) ($this->config['table_prefix'] ?? 'fxrate_');
}
public function settingsFile(): string
{
return (string) ($this->config['settings_file'] ?? dirname(__DIR__, 4) . '/data/fx-rates/settings.json');
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
final class SettingsStore
{
public function __construct(
private readonly ModuleConfig $moduleConfig
) {
}
public function load(): array
{
$defaults = $this->normalize($this->moduleConfig->all());
$file = $this->settingsFile();
if (!is_file($file)) {
return $defaults;
}
$raw = file_get_contents($file);
if ($raw === false || trim($raw) === '') {
return $defaults;
}
$decoded = json_decode($raw, true);
$saved = is_array($decoded) ? $decoded : [];
return $this->normalize(array_replace_recursive($defaults, $saved));
}
public function save(array $patch): array
{
$current = $this->load();
$next = $this->normalize(array_replace_recursive($current, $patch));
$next['updated_at'] = gmdate(DATE_ATOM);
$file = $this->settingsFile();
$dir = dirname($file);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($file, json_encode($next, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $next;
}
private function settingsFile(): string
{
return $this->moduleConfig->settingsFile();
}
private function normalize(array $settings): array
{
$preferredCurrencies = $settings['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE', 'BTC'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => strtoupper(trim((string) $value)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
))));
$catalog = $settings['currency_catalog'] ?? [];
$catalog = array_values(array_filter(array_map(static function (mixed $item): ?array {
if (!is_array($item)) {
return null;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
return null;
}
return [
'code' => $code,
'name' => $name,
];
}, is_array($catalog) ? $catalog : [])));
$refreshHours = (float) ($settings['refresh_max_age_hours'] ?? 6);
if ($refreshHours <= 0) {
$refreshHours = 6;
}
$displayBase = strtoupper(trim((string) ($settings['display_base_currency'] ?? ($settings['default_base_currency'] ?? 'EUR'))));
$defaultBase = strtoupper(trim((string) ($settings['default_base_currency'] ?? 'EUR')));
if ($defaultBase === '') {
$defaultBase = 'EUR';
}
if ($displayBase === '') {
$displayBase = $defaultBase;
}
return [
'use_separate_db' => !empty($settings['use_separate_db']),
'db' => is_array($settings['db'] ?? null) ? $settings['db'] : [],
'provider' => trim((string) ($settings['provider'] ?? 'currencyapi')) ?: 'currencyapi',
'api_version' => in_array(strtolower(trim((string) ($settings['api_version'] ?? 'v2'))), ['v2', 'v3'], true)
? strtolower(trim((string) ($settings['api_version'] ?? 'v2')))
: 'v2',
'api_url' => rtrim((string) ($settings['api_url'] ?? 'https://currencyapi.net'), '/'),
'currencies_url' => rtrim((string) ($settings['currencies_url'] ?? ($settings['api_url'] ?? 'https://currencyapi.net')), '/'),
'api_key' => trim((string) ($settings['api_key'] ?? '')),
'timeout_sec' => max(2, (int) ($settings['timeout_sec'] ?? 10)),
'refresh_max_age_hours' => $refreshHours,
'refresh_max_age_minutes' => max(1, (int) round($refreshHours * 60)),
'default_base_currency' => $defaultBase,
'display_base_currency' => $displayBase,
'preferred_currencies' => $preferredCurrencies,
'currency_catalog' => $catalog,
'currency_catalog_synced_at' => trim((string) ($settings['currency_catalog_synced_at'] ?? '')),
'schedule_timezone' => trim((string) ($settings['schedule_timezone'] ?? nexus_display_timezone_name())) ?: nexus_display_timezone_name(),
'updated_at' => trim((string) ($settings['updated_at'] ?? '')),
];
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/mining-checker/index.php/';
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
if ($relativePath === '') {
$relativePath = str_starts_with($requestPath, $prefix)
? substr($requestPath, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
}
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($relativePath);

View File

@@ -0,0 +1,828 @@
#mining-checker-app {
--mc-surface: #ffffff;
--mc-surface-strong: #ffffff;
--mc-line: rgba(148, 163, 184, 0.24);
--mc-line-strong: rgba(37, 99, 235, 0.24);
--mc-text: #0f172a;
--mc-text-muted: #475569;
--mc-accent: #2563eb;
--mc-accent-strong: #1d4ed8;
--mc-danger: #d92d20;
--mc-success: #14804a;
--mc-warning: #b54708;
min-height: 0;
background: #f8fafc;
color: var(--mc-text);
font-family: inherit;
max-width: 100%;
overflow-x: clip;
height: 100%;
}
#mining-checker-app,
#mining-checker-app * {
box-sizing: border-box;
}
#mining-checker-app .mc-grid-bg {
background: none;
max-width: 100%;
overflow-x: clip;
}
#mining-checker-app .mc-shell {
width: 100%;
max-width: 100%;
margin: 0;
padding: 0;
height: 100%;
}
#mining-checker-app .mc-sidebar-title {
font-size: 1.35rem;
}
#mining-checker-app .mc-nav-meta {
font-size: 0.85rem;
line-height: 1.45;
color: var(--mc-text-muted);
}
#mining-checker-app .mc-hero-panel {
border: 1px solid var(--mc-line);
background: var(--mc-surface);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
#mining-checker-app .mc-inline-row--wrap {
flex-wrap: wrap;
}
#mining-checker-app .mc-stack {
display: grid;
gap: 16px;
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-target-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell,
#mining-checker-app .mc-display-field {
border: 1px solid var(--mc-line);
border-radius: 16px;
background: var(--mc-surface);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
padding: 18px 20px;
}
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-inline-row,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
display: flex;
gap: 16px;
}
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
justify-content: space-between;
}
#mining-checker-app .mc-hero-copy,
#mining-checker-app .mc-hero-controls,
#mining-checker-app .mc-panel-body,
#mining-checker-app .mc-form,
#mining-checker-app .mc-field,
#mining-checker-app .mc-filter-grid,
#mining-checker-app .mc-chart,
#mining-checker-app .mc-target-grid {
display: grid;
gap: 14px;
}
#mining-checker-app .mc-filter-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#mining-checker-app .mc-text,
#mining-checker-app p,
#mining-checker-app td,
#mining-checker-app th,
#mining-checker-app label,
#mining-checker-app summary,
#mining-checker-app pre {
color: var(--mc-text-muted);
}
#mining-checker-app h1,
#mining-checker-app h2,
#mining-checker-app h3 {
margin: 0;
color: var(--mc-text);
}
#mining-checker-app .mc-stats-grid,
#mining-checker-app .mc-target-grid,
#mining-checker-app .mc-overview-grid,
#mining-checker-app .mc-two-col,
#mining-checker-app .mc-main-grid {
display: grid;
gap: 16px;
}
#mining-checker-app .mc-stats-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#mining-checker-app .mc-overview-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
#mining-checker-app .mc-target-grid {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
#mining-checker-app .mc-two-col {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
#mining-checker-app .mc-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
}
#mining-checker-app .mc-asset-card {
gap: 8px;
}
#mining-checker-app .mc-asset-balance {
font-size: 1.15rem;
font-weight: 700;
color: var(--mc-text);
}
#mining-checker-app .mc-asset-list {
display: grid;
gap: 10px;
min-width: 240px;
}
#mining-checker-app .mc-history-stack {
display: grid;
gap: 14px;
}
#mining-checker-app .mc-history-card {
display: grid;
gap: 14px;
padding: 18px;
border: 1px solid var(--mc-line);
border-radius: 16px;
background: var(--mc-surface);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
#mining-checker-app .mc-history-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
#mining-checker-app .mc-history-asset-card {
display: grid;
gap: 6px;
padding: 12px 14px;
border: 1px solid var(--mc-line);
border-radius: 14px;
background: #fff;
}
#mining-checker-app .mc-history-asset-card strong {
color: var(--mc-text);
}
#mining-checker-app .mc-asset-row {
display: grid;
gap: 2px;
}
#mining-checker-app .mc-asset-row strong {
color: var(--mc-text);
}
#mining-checker-app .mc-main-grid {
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
}
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-target-card {
padding: 20px;
}
#mining-checker-app .mc-stat-card {
background: var(--mc-surface-strong);
}
#mining-checker-app .mc-kicker {
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-stat-value {
font-size: 2rem;
font-weight: 700;
line-height: 1.1;
color: var(--mc-text);
}
#mining-checker-app .mc-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
border: 1px solid var(--mc-line-strong);
background: #eff6ff;
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-badge--warn {
background: color-mix(in srgb, var(--mc-warning) 12%, transparent);
color: var(--mc-warning);
}
#mining-checker-app .mc-badge--info {
background: #eff6ff;
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-badge--danger {
background: color-mix(in srgb, var(--mc-danger) 12%, transparent);
color: var(--mc-danger);
}
#mining-checker-app .mc-badge--success {
background: color-mix(in srgb, var(--mc-success) 12%, transparent);
color: var(--mc-success);
}
#mining-checker-app .mc-button,
#mining-checker-app button,
#mining-checker-app input,
#mining-checker-app select,
#mining-checker-app textarea {
font: inherit;
}
#mining-checker-app .mc-button {
border: 1px solid transparent;
border-radius: 999px;
padding: 11px 16px;
cursor: pointer;
text-decoration: none;
transition: 160ms ease;
}
#mining-checker-app .mc-button:hover {
transform: translateY(-1px);
}
#mining-checker-app .mc-button:disabled {
opacity: 0.6;
cursor: default;
transform: none;
}
#mining-checker-app .mc-button--primary {
background: var(--mc-accent);
color: #f8fafc;
font-weight: 700;
}
#mining-checker-app .mc-button--secondary {
background: #ffffff;
border-color: #cbd5e1;
color: var(--mc-text);
font-weight: 700;
}
#mining-checker-app .mc-button--danger {
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
color: #fff7f7;
font-weight: 700;
}
#mining-checker-app .mc-button--ghost {
background: #eff6ff;
border-color: rgba(37, 99, 235, 0.18);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-debug-tools {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-start;
}
#mining-checker-app .mc-debug-console {
border-color: rgba(125, 211, 252, 0.28);
}
#mining-checker-app .mc-debug-view-switch {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
#mining-checker-app .mc-debug-log {
display: grid;
gap: 12px;
max-height: 520px;
overflow: auto;
}
#mining-checker-app .mc-debug-text-console {
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
#mining-checker-app .mc-debug-entry {
border: 1px solid var(--mc-line);
border-radius: 16px;
padding: 14px 16px;
background: #f8fafc;
}
#mining-checker-app .mc-field {
gap: 8px;
}
#mining-checker-app .mc-field-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--mc-text-muted);
}
#mining-checker-app .mc-input,
#mining-checker-app .mc-select,
#mining-checker-app .mc-textarea,
#mining-checker-app .mc-file {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 12px;
padding: 11px 12px;
color: var(--mc-text);
background: #ffffff;
outline: none;
}
#mining-checker-app .mc-select option {
color: #09111f;
background: #f8fafc;
}
#mining-checker-app .mc-textarea {
min-height: 120px;
resize: vertical;
}
#mining-checker-app .mc-input::placeholder,
#mining-checker-app .mc-textarea::placeholder {
color: #6d7c90;
}
#mining-checker-app .mc-input:focus,
#mining-checker-app .mc-select:focus,
#mining-checker-app .mc-textarea:focus,
#mining-checker-app .mc-file:focus {
border-color: var(--mc-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
#mining-checker-app .mc-checkbox {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--mc-line);
border-radius: 12px;
padding: 14px 16px;
background: #ffffff;
}
#mining-checker-app .mc-inline-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: end;
}
#mining-checker-app .mc-inline-fields > .mc-field {
flex: 1 1 280px;
}
#mining-checker-app .mc-alert--error {
border-color: rgba(185, 28, 28, 0.18);
background: #fef2f2;
color: #991b1b;
}
#mining-checker-app .mc-alert--warning {
border-color: rgba(217, 119, 6, 0.18);
background: #fff7ed;
color: #b45309;
}
#mining-checker-app .mc-alert--success {
border-color: rgba(5, 150, 105, 0.18);
background: #ecfdf5;
color: #047857;
}
#mining-checker-app .mc-table-shell {
overflow: auto;
padding: 0;
}
#mining-checker-app .mc-table {
width: 100%;
border-collapse: collapse;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 14px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
text-align: left;
vertical-align: top;
}
#mining-checker-app .mc-table thead {
background: #f8fafc;
}
#mining-checker-app .mc-empty {
border-style: dashed;
}
#mining-checker-app details {
border: 1px solid var(--mc-line);
border-radius: 16px;
padding: 16px;
background: #ffffff;
}
#mining-checker-app pre {
white-space: pre-wrap;
margin: 12px 0 0;
line-height: 1.65;
}
#mining-checker-app .mc-mini-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
#mining-checker-app .mc-mini-card,
#mining-checker-app .mc-display-field {
padding: 12px 14px;
background: #ffffff;
}
#mining-checker-app .mc-code-block {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--mc-line);
border-radius: 16px;
background: #f8fafc;
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 0.92rem;
line-height: 1.6;
}
#mining-checker-app .mc-chart svg {
width: 100%;
height: 220px;
}
#mining-checker-app .mc-modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(2, 6, 23, 0.72);
}
#mining-checker-app .mc-modal {
width: min(720px, 100%);
max-height: min(80vh, 900px);
overflow: auto;
padding: 24px;
border: 1px solid var(--mc-line);
border-radius: 20px;
background: #ffffff;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
}
#mining-checker-app .mc-chart path,
#mining-checker-app .mc-chart polyline,
#mining-checker-app .mc-chart line,
#mining-checker-app .mc-chart rect {
vector-effect: non-scaling-stroke;
}
@media (max-width: 960px) {
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-inline-row,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
flex-direction: column;
align-items: stretch;
}
#mining-checker-app .mc-main-grid {
grid-template-columns: 1fr;
}
#mining-checker-app .mc-shell {
width: min(100% - 10px, 1360px);
padding: 8px 0 20px;
}
#mining-checker-app .mc-stack {
gap: 14px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
padding: 14px;
border-radius: 20px;
}
#mining-checker-app .mc-title {
font-size: clamp(1.45rem, 8vw, 2.15rem);
}
#mining-checker-app .mc-hero-copy {
gap: 8px;
}
#mining-checker-app .mc-hero-copy p {
margin: 0;
}
#mining-checker-app .mc-hero-controls {
grid-template-columns: 1fr;
}
#mining-checker-app .mc-home-link {
justify-self: stretch;
justify-content: center;
}
#mining-checker-app .mc-tabs {
flex-wrap: nowrap;
gap: 8px;
margin: 12px -4px 0;
padding: 0 4px 4px;
overflow-x: auto;
scrollbar-width: thin;
}
#mining-checker-app .mc-button {
padding: 10px 12px;
border-radius: 14px;
}
#mining-checker-app .mc-button--tab {
flex: 0 0 auto;
white-space: nowrap;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 10px 12px;
}
#mining-checker-app .mc-modal {
max-height: min(92vh, 900px);
padding: 16px;
border-radius: 20px;
}
}
@media (max-width: 600px) {
#mining-checker-app,
#mining-checker-app * {
max-width: 100%;
}
#mining-checker-app {
min-height: 100vh;
overflow-x: hidden;
}
#mining-checker-app .mc-grid-bg {
overflow-x: hidden;
background-size: 18px 18px;
}
#mining-checker-app .mc-shell {
width: 100%;
padding: 0 0 16px;
}
#mining-checker-app .mc-stack {
gap: 12px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty {
width: 100%;
border-radius: 18px;
padding: 14px;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
}
#mining-checker-app .mc-hero {
border-radius: 0 0 18px 18px;
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
border-left: 0;
border-right: 0;
}
#mining-checker-app .mc-title,
#mining-checker-app .mc-hero-copy p {
display: none;
}
#mining-checker-app .mc-hero-top {
gap: 10px;
}
#mining-checker-app .mc-kicker {
font-size: 0.68rem;
letter-spacing: 0.14em;
}
#mining-checker-app .mc-tabs {
margin: 10px -6px 0;
padding: 0 6px 6px;
gap: 6px;
}
#mining-checker-app .mc-button {
min-height: 38px;
padding: 8px 10px;
border-radius: 12px;
font-size: 0.92rem;
}
#mining-checker-app .mc-button:hover {
transform: none;
}
#mining-checker-app .mc-stats-grid,
#mining-checker-app .mc-target-grid,
#mining-checker-app .mc-overview-grid,
#mining-checker-app .mc-two-col,
#mining-checker-app .mc-main-grid,
#mining-checker-app .mc-filter-grid,
#mining-checker-app .mc-mini-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-target-card,
#mining-checker-app .mc-mini-card,
#mining-checker-app .mc-display-field {
padding: 12px;
border-radius: 16px;
}
#mining-checker-app .mc-stat-value {
font-size: clamp(1.45rem, 8vw, 1.85rem);
overflow-wrap: anywhere;
}
#mining-checker-app h2 {
font-size: 1.25rem;
}
#mining-checker-app h3 {
font-size: 1.05rem;
}
#mining-checker-app .mc-text,
#mining-checker-app p,
#mining-checker-app td,
#mining-checker-app th,
#mining-checker-app label,
#mining-checker-app summary,
#mining-checker-app pre {
font-size: 0.92rem;
}
#mining-checker-app .mc-input,
#mining-checker-app .mc-select,
#mining-checker-app .mc-textarea,
#mining-checker-app .mc-file {
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
}
#mining-checker-app .mc-inline-fields,
#mining-checker-app .mc-debug-tools,
#mining-checker-app .mc-debug-view-switch {
gap: 8px;
}
#mining-checker-app .mc-inline-fields > .mc-field {
flex-basis: 100%;
min-width: 0;
}
#mining-checker-app .mc-badge {
padding: 7px 10px;
font-size: 0.78rem;
}
#mining-checker-app .mc-table-shell {
width: 100%;
max-width: 100%;
border-radius: 16px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
#mining-checker-app .mc-table {
max-width: none;
min-width: 640px;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 9px 10px;
font-size: 0.86rem;
}
#mining-checker-app .mc-chart svg {
height: 180px;
}
#mining-checker-app .mc-modal-backdrop {
align-items: stretch;
padding: 8px;
}
#mining-checker-app .mc-modal {
width: 100%;
max-height: calc(100vh - 16px);
padding: 14px;
border-radius: 18px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\MiningChecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
'MINING_CHECKER_FX_TIMEOUT' => '10',
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
];

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
return [
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
'use_project_database' => true,
'table_prefix' => 'miningcheck_',
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
'uploads_public_prefix' => '/data/mining-checker/uploads',
'ocr' => [
'providers' => array_values(array_filter(array_map(
static fn (string $provider): string => trim(strtolower($provider)),
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
))),
'ocrspace' => [
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
],
'tesseract' => [
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
],
],
'fx' => [
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
'auto_fetch_on_miss' => filter_var(
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
FILTER_VALIDATE_BOOL
),
],
'debug' => [
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
],
];

View File

@@ -0,0 +1,17 @@
{
"eyebrow": "Modul",
"title": "Mining-Checker",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"actions": [
{ "label": "Nexus Übersicht", "href": "/modules" },
{ "label": "Setup", "href": "/apps/mining-checker?view=setup", "variant": "secondary" }
],
"sections": [
{ "key": "overview", "label": "Übersicht" },
{ "key": "upload", "label": "Upload" },
{ "key": "measurements", "label": "Mining-History" },
{ "key": "wallet", "label": "NC Wallet" },
{ "key": "mining", "label": "Miner-Daten" },
{ "key": "dashboards", "label": "Dashboards" }
]
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
$design = [];
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$sections = array_values(array_filter(
is_array($design['sections'] ?? null) ? $design['sections'] : [],
static fn (mixed $section): bool => is_array($section)
));
$assetVersion = static function (string $relativePath): string {
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
return [
'app_id' => 'mining-checker',
'title' => (string) ($manifest['title'] ?? 'Mining-Checker'),
'icon' => 'MC',
'entry_route' => '/apps/mining-checker',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1180,
'default_height' => 760,
'supports_widget' => false,
'supports_tray' => true,
'module_name' => 'finance',
'summary' => (string) ($manifest['description'] ?? 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.'),
'native_module' => [
'initializer' => 'initMiningCheckerApp',
'options' => [
'apiBase' => '/api/mining-checker/index.php?path=v1',
'defaultProjectKey' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
'activeView' => 'overview',
'sections' => $sections,
'moduleDebugEnabled' => false,
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=mining-checker&path=assets/css/app.css&v=' . rawurlencode($assetVersion('assets/css/app.css'))],
],
'scripts' => [
['src' => 'https://unpkg.com/react@18/umd/react.production.min.js', 'crossorigin' => true],
['src' => 'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js', 'crossorigin' => true],
['src' => '/module-assets/index.php?module=mining-checker&path=assets/js/app.js&v=' . rawurlencode($assetVersion('assets/js/app.js'))],
],
],
],
];

View File

@@ -0,0 +1,114 @@
# Mining-Checker Modul
## Zweck
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
## Ordnerstruktur
```text
custom/apps/mining-checker/
|-- api/
|-- assets/
| |-- css/
| `-- js/
|-- config/
|-- docs/
|-- pages/
|-- partials/
|-- sql/
| `-- migrations/
|-- src/
| |-- Api/
| |-- Domain/
| |-- Infrastructure/
| `-- Support/
|-- storage/uploads/
|-- bootstrap.php
`-- module.json
```
## API-Endpunkte
- `GET /api/mining-checker/v1/health`
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
## Integration
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
- MySQL/MariaDB: `sql/schema.mysql.sql`
- PostgreSQL: `sql/schema.pgsql.sql`
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
3. Modulroute ueber `/module/mining-checker` aufrufen.
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
Hinweis:
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
Seed-Daten werden dabei nicht automatisch eingespielt.
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
## OCR-Hinweis
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
Empfohlene Umgebungsvariablen:
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
- `MINING_CHECKER_TESSERACT_LANG=eng`
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
## Wechselkurse und Waehrungen
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
Optionaler JSON-Body:
- `base`: Standard `EUR`
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
Beispiel:
```json
{
"base": "EUR"
}
```
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.

View File

@@ -0,0 +1,37 @@
{
"name": "Mining-Checker",
"title": "Mining-Checker",
"version": "0.3.0",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"enabled_by_default": true,
"app_scope": "module",
"installable": true,
"desktop": {
"available": true,
"show_on_desktop": true,
"show_in_start_menu": true
},
"setup": {
"sections": {
"database": true
},
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
]
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$design = [];
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
$assetVersion = static function (string $relativePath): string {
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
$sectionKeys = array_values(array_filter(array_map(
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
$sections
)));
if ($sectionKeys === []) {
$sectionKeys = ['overview'];
}
if (!in_array($activeView, $sectionKeys, true)) {
$activeView = $sectionKeys[0];
}
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mining-Checker</title>
<style>
:root {
color-scheme: light;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #475569;
--brand-accent: #14b8a6;
--brand-accent-3: #0f766e;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: var(--text);
}
.mining-module-shell {
min-height: 100vh;
padding: 18px;
}
</style>
<link rel="stylesheet" href="/module-assets/index.php?module=mining-checker&amp;path=assets/css/app.css&amp;v=<?= htmlspecialchars($assetVersion('assets/css/app.css'), ENT_QUOTES) ?>">
</head>
<body data-module-debug-enabled="0">
<div class="mining-module-shell">
<div
id="mining-checker-app"
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
data-api-base="/api/mining-checker/index.php?path=v1"
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
></div>
</div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="/module-assets/index.php?module=mining-checker&amp;path=assets/js/app.js&amp;v=<?= htmlspecialchars($assetVersion('assets/js/app.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,124 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);

View File

@@ -0,0 +1,75 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

View File

@@ -0,0 +1,34 @@
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
UPDATE miningcheck_settings
SET display_timezone = 'Europe/Berlin'
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
UPDATE miningcheck_settings
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE baseline_measured_at IS NOT NULL;
UPDATE miningcheck_cost_plans
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE starts_at IS NOT NULL;
UPDATE miningcheck_measurements
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE measured_at IS NOT NULL;
UPDATE miningcheck_payouts
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE payout_at IS NOT NULL;
UPDATE miningcheck_purchased_miners
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE purchased_at IS NOT NULL;
UPDATE miningcheck_fx_fetches
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE fetched_at IS NOT NULL;
COMMIT;

View File

@@ -0,0 +1,72 @@
BEGIN;
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
SELECT *
FROM miningcheck_cost_plans
WHERE 1 = 0;
INSERT INTO miningcheck_cost_plans_legacy
SELECT cp.*
FROM miningcheck_cost_plans cp
LEFT JOIN miningcheck_cost_plans_legacy legacy
ON legacy.id = cp.id
WHERE legacy.id IS NULL;
INSERT INTO miningcheck_purchased_miners (
project_key,
miner_offer_id,
purchased_at,
label,
runtime_months,
mining_speed_value,
mining_speed_unit,
bonus_speed_value,
bonus_speed_unit,
total_cost_amount,
currency,
usd_reference_amount,
reference_price_amount,
reference_price_currency,
auto_renew,
note,
is_active
)
SELECT
cp.project_key,
NULL AS miner_offer_id,
cp.starts_at AS purchased_at,
cp.label,
cp.runtime_months,
cp.mining_speed_value,
cp.mining_speed_unit,
cp.bonus_speed_value,
cp.bonus_speed_unit,
cp.total_cost_amount,
cp.currency,
CASE
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
ELSE NULL
END AS usd_reference_amount,
cp.base_price_amount AS reference_price_amount,
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
cp.auto_renew,
CASE
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
END AS note,
cp.is_active
FROM miningcheck_cost_plans cp
LEFT JOIN miningcheck_settings s
ON s.project_key = cp.project_key
LEFT JOIN miningcheck_purchased_miners pm
ON pm.project_key = cp.project_key
AND pm.miner_offer_id IS NULL
AND pm.purchased_at = cp.starts_at
AND pm.label = cp.label
AND pm.total_cost_amount = cp.total_cost_amount
AND pm.currency = cp.currency
WHERE pm.id IS NULL;
DELETE FROM miningcheck_cost_plans;
COMMIT;

View File

@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
UPDATE miningcheck_settings
SET module_theme_mode = 'inherit'
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
UPDATE miningcheck_settings
SET module_theme_accent = 'teal'
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
COMMIT;

View File

@@ -0,0 +1,182 @@
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
-- adea1766-5d1c-4c2e-98bd-5239861f745f
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_cost_plans
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_measurements
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_measurement_rates
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_payouts
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_targets
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_dashboard_definitions
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_purchased_miners
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
UPDATE miningcheck_settings
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_cost_plans
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_measurements
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_measurement_rates mr
SET owner_sub = m.owner_sub
FROM miningcheck_measurements m
WHERE mr.measurement_id = m.id
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
UPDATE miningcheck_measurement_rates
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_payouts
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_targets
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_dashboard_definitions
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_purchased_miners
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
ALTER TABLE miningcheck_settings
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_cost_plans
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_measurements
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_measurement_rates
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_payouts
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_targets
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_dashboard_definitions
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_purchased_miners
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_settings
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
ALTER TABLE miningcheck_measurements
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
ALTER TABLE miningcheck_targets
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
ALTER TABLE miningcheck_dashboard_definitions
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_settings'
AND constraint_name = 'uq_mining_settings_project_owner'
) THEN
ALTER TABLE miningcheck_settings
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_measurements'
AND constraint_name = 'uq_mining_measurements_unique'
) THEN
ALTER TABLE miningcheck_measurements
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_targets'
AND constraint_name = 'uq_mining_targets_project_label'
) THEN
ALTER TABLE miningcheck_targets
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_dashboard_definitions'
AND constraint_name = 'uq_mining_dashboards_project_name'
) THEN
ALTER TABLE miningcheck_dashboard_definitions
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
COMMIT;

View File

@@ -0,0 +1,237 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
fx_fetch_id BIGINT UNSIGNED NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE INDEX idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_withdrawals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
withdrawal_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
withdrawal_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,259 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
baseline_measured_at TIMESTAMP NOT NULL,
baseline_coins_total NUMERIC(20,6) NOT NULL,
daily_cost_amount NUMERIC(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10),
crypto_currency VARCHAR(10),
display_timezone VARCHAR(64),
fx_max_age_hours NUMERIC(10,2),
module_theme_mode VARCHAR(16),
module_theme_accent VARCHAR(16),
preferred_currencies JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at TIMESTAMP NOT NULL,
runtime_months INTEGER NOT NULL,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
base_price_amount NUMERIC(20,8),
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
coins_total NUMERIC(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin NUMERIC(20,8),
price_currency VARCHAR(10),
fx_fetch_id BIGINT,
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGSERIAL PRIMARY KEY,
measurement_id BIGINT NOT NULL,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate NUMERIC(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount NUMERIC(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount NUMERIC(20,8),
total_value_currency VARCHAR(10),
wallet_balance NUMERIC(28,10),
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSONB,
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_withdrawals (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
withdrawal_at TIMESTAMP NOT NULL,
coins_amount NUMERIC(20,6) NOT NULL,
withdrawal_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_withdrawals_project_owner_at
ON miningcheck_wallet_withdrawals(project_key, owner_sub, withdrawal_at);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
base_price_amount NUMERIC(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat NUMERIC(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
miner_offer_id BIGINT,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount NUMERIC(20,8),
reference_price_amount NUMERIC(20,8),
reference_price_currency VARCHAR(10),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGSERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
ON miningcheck_fx_fetches(base_currency, fetched_at);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGSERIAL PRIMARY KEY,
fetch_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value NUMERIC(20,10) NOT NULL,
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
ON miningcheck_fx_rates(fetch_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
ON miningcheck_fx_rates(currency_code);

View File

@@ -0,0 +1,237 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
fx_fetch_id BIGINT UNSIGNED NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE INDEX idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_withdrawals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
withdrawal_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
withdrawal_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,75 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,980 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\FxRates\Domain\FxRatesService as SharedFxRatesService;
use Modules\FxRates\Infrastructure\ConnectionFactory as SharedFxConnectionFactory;
use Modules\FxRates\Infrastructure\FxRatesRepository as SharedFxRatesRepository;
use Modules\FxRates\Infrastructure\ModuleConfig as SharedFxModuleConfig;
use Modules\FxRates\Infrastructure\SettingsStore as SharedFxSettingsStore;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Support\DebugTrace;
final class FxService
{
private ?MiningRepository $repository;
private string $provider;
private string $apiBaseUrl;
private string $currenciesApiBaseUrl;
private string $apiKey;
private int $timeout;
private int $cacheTtl;
private bool $autoFetchOnMiss;
private array $memoryCache = [];
private array $snapshotCache = [];
private ?DebugTrace $debug;
private bool $sharedServiceResolved = false;
private ?object $sharedService = null;
public function __construct(
?MiningRepository $repository = null,
string $apiBaseUrl = 'https://currencyapi.net',
string $currenciesApiBaseUrl = 'https://currencyapi.net',
int $timeout = 10,
int $cacheTtl = 21600,
bool $autoFetchOnMiss = false,
string $provider = 'currencyapi',
string $apiKey = '',
?DebugTrace $debug = null
)
{
$this->repository = $repository;
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
$this->apiKey = trim($apiKey);
$this->timeout = max(2, $timeout);
$this->cacheTtl = max(60, $cacheTtl);
$this->autoFetchOnMiss = $autoFetchOnMiss;
$this->debug = $debug;
}
public function convert(?float $amount, ?string $from, ?string $to): ?float
{
return $this->convertAt($amount, $from, $to, null, null, null);
}
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
if ($amount === null || $from === null || $to === null) {
return null;
}
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null) {
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
if ($resolved !== null) {
return $amount * $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'convert')) {
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
return is_numeric($converted) ? (float) $converted : null;
}
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
return $rate === null ? null : $amount * $rate;
}
public function rate(?string $from, ?string $to): ?float
{
return $this->rateAt($from, $to, null, null, null);
}
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
$base = strtoupper(trim((string) $from));
$target = strtoupper(trim((string) $to));
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
if ($base === '' || $target === '') {
return null;
}
if ($base === $target) {
return 1.0;
}
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null) {
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
if ($resolved !== null) {
return $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'findRate')) {
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
}
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
$stored = $this->storedRate($base, $target);
if ($stored !== null) {
$this->memoryCache[$cacheKey] = $stored;
return $stored;
}
$cached = $this->readFileCache($cacheKey);
if ($cached !== null) {
$this->memoryCache[$cacheKey] = $cached;
return $cached;
}
if (!$this->autoFetchOnMiss) {
return null;
}
$rate = $this->fetchAndPersistRate($base, $target);
$this->memoryCache[$cacheKey] = $rate;
if ($rate !== null) {
$this->writeFileCache($cacheKey, $rate);
}
return $rate;
}
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
{
if ($fetchId <= 0) {
return null;
}
$cacheKey = $this->snapshotCacheKey('fetch', [
$fetchId,
strtoupper(trim((string) ($baseCurrency ?? ''))),
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
{
$cacheKey = $this->snapshotCacheKey('latest', [
strtoupper(trim((string) ($baseCurrency ?? ''))),
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshot')) {
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$cacheKey = $this->snapshotCacheKey('nearest', [
strtoupper(trim((string) ($baseCurrency ?? ''))),
trim($at),
$windowMinutes ?? 0,
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
return $shared->refreshLatestRates($currencies, $base);
}
$normalizedBase = strtoupper(trim($base));
$targets = $currencies === null
? null
: array_values(array_unique(array_filter(array_map(
static fn ($code): string => strtoupper(trim((string) $code)),
$currencies
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$forwardRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$targetCode = strtoupper((string) $target);
if ($targetCode === '' || $targetCode === $normalizedBase) {
continue;
}
$forwardRates[$targetCode] = (float) $rate;
}
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
return [
'base' => $normalizedBase,
'rate_date' => $rateDate,
'updated_count' => count($updated),
'rates' => $updated,
];
}
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
}
$normalizedBase = strtoupper(trim($base));
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
if ($this->repository === null) {
return $this->refreshLatestRates(null, $normalizedBase);
}
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
$this->debug?->add('fx.latest.reuse', [
'base' => $normalizedBase,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
return [
'base' => $normalizedBase,
'rate_date' => $latestFetch['rate_date'] ?? null,
'updated_count' => 0,
'rates' => [],
'reused' => true,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
];
}
$this->debug?->add('fx.latest.refresh_required', [
'base' => $normalizedBase,
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
$result = $this->refreshLatestRates(null, $normalizedBase);
$result['reused'] = false;
return $result;
}
public function probeLatestRates(string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
return $shared->probeLatestRates($base);
}
$normalizedBase = strtoupper(trim($base));
return $this->fetchLatestProbe($normalizedBase);
}
public function refreshCurrencyCatalog(): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
return $shared->refreshCurrencyCatalog();
}
$payload = $this->fetchCurrenciesPayload();
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
if ($items === []) {
return [
'synced_count' => 0,
'currencies' => [],
];
}
$synced = [];
$sortOrder = 1000;
foreach ($items as $code => $name) {
$normalizedCode = strtoupper(trim((string) $code));
$normalizedName = trim((string) $name);
if ($normalizedCode === '' || $normalizedName === '') {
continue;
}
$currency = [
'code' => substr($normalizedCode, 0, 10),
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
'symbol' => substr($normalizedCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
];
$synced[] = $currency;
$sortOrder++;
}
usort($synced, static function (array $left, array $right): int {
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
});
return [
'synced_count' => count($synced),
'currencies' => $synced,
];
}
public function probeCurrencyCatalog(): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
return $shared->probeCurrencyCatalog();
}
return $this->fetchCurrenciesProbe();
}
private function fetchAndPersistRate(string $base, string $target): ?float
{
$payload = $this->fetchLatestPayload($base, [$target]);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rate = $rates[$target] ?? null;
if (!is_numeric($rate)) {
return null;
}
$numericRate = (float) $rate;
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
return $numericRate;
}
private function fetchLatestPayload(string $base, ?array $targets = null): array
{
if (!function_exists('curl_init')) {
return [];
}
$url = $this->buildLatestUrl($base, $targets);
if ($url === null) {
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
return [];
}
$this->debug?->add('fx.latest.request', [
'base' => $base,
'url' => $this->maskUrl($url),
'targets' => $targets,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.latest.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
return $this->normalizePayload($payload, $base, $targets);
}
private function fetchLatestProbe(string $base): array
{
if (!function_exists('curl_init')) {
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
}
$url = $this->buildLatestUrl($base, null);
if ($url === null) {
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
}
$this->debug?->add('fx.latest.probe.request', [
'base' => $base,
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.latest.probe.response', $result);
return $result;
}
private function fetchCurrenciesPayload(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return [];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.currencies.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
return $payload;
}
private function fetchCurrenciesProbe(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.probe.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.currencies.probe.response', $result);
return $result;
}
private function storedRate(string $base, string $target): ?float
{
if ($this->repository === null) {
return null;
}
try {
$direct = $this->repository->getLatestFxRate($base, $target);
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
return (float) $direct['rate'];
}
$inverse = $this->repository->getLatestFxRate($target, $base);
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
return 1 / (float) $inverse['rate'];
}
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
return (float) $measurementRate['rate'];
}
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
if (
is_array($inverseMeasurementRate) &&
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
(float) $inverseMeasurementRate['rate'] > 0
) {
return 1 / (float) $inverseMeasurementRate['rate'];
}
foreach (['USD', 'EUR'] as $viaBase) {
if ($base === $viaBase || $target === $viaBase) {
continue;
}
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
if (
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
(float) $fromVia['rate'] > 0
) {
return (float) $toVia['rate'] / (float) $fromVia['rate'];
}
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
if (
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
(float) $toViaInverse['rate'] > 0
) {
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
}
}
} catch (\Throwable) {
return null;
}
return null;
}
private function persistRateSet(string $base, array $rates, string $rateDate): array
{
$normalizedBase = strtoupper($base);
$normalizedRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$normalizedTarget = strtoupper((string) $target);
$normalizedRates[$normalizedTarget] = (float) $rate;
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
}
if ($this->repository === null) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
try {
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
} catch (\Throwable) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
}
private function buildLatestUrl(string $base, ?array $targets = null): ?string
{
if ($this->provider === 'currencyapi') {
if ($this->apiKey === '') {
return null;
}
return sprintf(
'%s/api/v2/rates?base=%s&output=json&key=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode($this->apiKey)
);
}
$targets = $targets ?? $this->defaultCurrencies();
return sprintf(
'%s/latest?base=%s&symbols=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode(implode(',', $targets))
);
}
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
{
if (!is_array($payload)) {
return [];
}
if ($this->provider === 'currencyapi') {
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
}
$allRates = $payload['rates'];
$filteredRates = [];
if ($targets === null) {
foreach ($allRates as $target => $rate) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base || !is_numeric($rate)) {
continue;
}
$filteredRates[$targetCode] = (float) $rate;
}
} else {
foreach ($targets as $target) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base) {
continue;
}
$rate = $allRates[$targetCode] ?? null;
if (is_numeric($rate)) {
$filteredRates[$targetCode] = (float) $rate;
}
}
}
return [
'base' => strtoupper((string) ($payload['base'] ?? $base)),
'date' => $payload['updated'] ?? null,
'rates' => $filteredRates,
];
}
if (!is_array($payload['rates'] ?? null)) {
return [];
}
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
return [];
}
return $payload;
}
private function extractProviderError(array $payload, string $fallback): string
{
foreach (['error', 'message', 'msg'] as $field) {
$value = $payload[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
$errors = $payload['errors'] ?? null;
if (is_array($errors)) {
$flat = [];
array_walk_recursive($errors, static function ($value) use (&$flat): void {
if (is_string($value) && trim($value) !== '') {
$flat[] = trim($value);
}
});
if ($flat !== []) {
return implode(' | ', array_values(array_unique($flat)));
}
}
return $fallback;
}
private function defaultCurrencies(): array
{
return ['EUR', 'USD'];
}
private function normalizeRateDate(mixed $value): string
{
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
$timestamp = (int) $value;
if ($timestamp > 0) {
return date('Y-m-d', $timestamp);
}
}
if (is_string($value) && trim($value) !== '') {
$timestamp = strtotime($value);
if ($timestamp !== false) {
return date('Y-m-d', $timestamp);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
return $matches[0];
}
}
return date('Y-m-d');
}
private function parseStoredUtcTimestamp(string $value): ?int
{
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
} else {
$date = new \DateTimeImmutable($normalized);
}
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
private function catalogSortOrder(string $code, int $fallback): int
{
return match (strtoupper($code)) {
'EUR' => 10,
'USD' => 20,
'DOGE' => 30,
'BTC' => 40,
'ETH' => 50,
'USDT' => 60,
'USDC' => 70,
default => $fallback,
};
}
private function isCryptoCode(string $code): bool
{
return in_array(strtoupper($code), [
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XRP',
], true);
}
private function cacheFile(string $cacheKey): string
{
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
}
private function readFileCache(string $cacheKey): ?float
{
$file = $this->cacheFile($cacheKey);
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
return null;
}
$payload = json_decode((string) file_get_contents($file), true);
$rate = $payload['rate'] ?? null;
return is_numeric($rate) ? (float) $rate : null;
}
private function writeFileCache(string $cacheKey, float $rate): void
{
@file_put_contents($this->cacheFile($cacheKey), json_encode([
'rate' => $rate,
'cached_at' => time(),
], JSON_UNESCAPED_UNICODE));
}
private function maskUrl(string $url): string
{
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
$key = $matches[2] ?? '';
if (strlen($key) <= 8) {
return $matches[1] . $key;
}
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
}, $url) ?: $url;
}
private function sharedFxService(): ?object
{
if ($this->sharedServiceResolved) {
return $this->sharedService;
}
try {
$moduleBasePath = dirname(__DIR__, 2) . '/../fx-rates';
$bootstrap = $moduleBasePath . '/bootstrap.php';
if (!is_file($bootstrap)) {
$this->sharedServiceResolved = true;
return $this->sharedService = null;
}
require_once $bootstrap;
if (!class_exists(SharedFxModuleConfig::class) || !class_exists(SharedFxRatesService::class)) {
$this->sharedServiceResolved = true;
return $this->sharedService = null;
}
$config = SharedFxModuleConfig::load($moduleBasePath);
$settingsStore = new SharedFxSettingsStore($config);
$repository = new SharedFxRatesRepository(
SharedFxConnectionFactory::make($settingsStore),
$config->tablePrefix()
);
$repository->ensureSchema();
$this->sharedServiceResolved = true;
return $this->sharedService = new SharedFxRatesService($repository, $settingsStore->load());
} catch (\Throwable) {
$this->sharedServiceResolved = true;
return $this->sharedService = null;
}
}
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
{
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
if ($base === '' || $from === '' || $to === '') {
return null;
}
if ($base === $from && is_numeric($rates[$to] ?? null)) {
return (float) $rates[$to];
}
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
return 1 / (float) $rates[$from];
}
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
return (float) $rates[$to] / (float) $rates[$from];
}
return null;
}
private function snapshotCacheKey(string $prefix, array $parts): string
{
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
}
private function normalizeSymbolsForCache(?array $symbols): string
{
if (!is_array($symbols) || $symbols === []) {
return '*';
}
$normalized = array_values(array_unique(array_filter(array_map(
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
$symbols
))));
sort($normalized);
return implode(',', $normalized);
}
}

View File

@@ -0,0 +1,665 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Support\ApiException;
final class OcrService
{
private ModuleConfig $config;
public function __construct(ModuleConfig $config)
{
$this->config = $config;
}
public function preview(array $file, array $input): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
}
$mime = mime_content_type($file['tmp_name']) ?: '';
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
}
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
$uploadDir = $this->resolveUploadDir($projectKey);
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
$targetFile = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
}
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
$flags = [];
if ($rawText === '') {
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
$flags = array_merge($flags, $providerFlags);
} else {
$flags[] = 'ocr_hint_text_used';
}
$parsed = $this->parseText(
$rawText,
(string) ($input['date_context'] ?? date('Y-m-d')),
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
);
$parsed['image_path'] = $targetFile;
$parsed['raw_text'] = $rawText;
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
return $parsed;
}
private function resolveUploadDir(string $projectKey): string
{
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
$candidates = [
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
];
foreach ($candidates as $candidate) {
if ($this->ensureWritableDirectory($candidate)) {
return $candidate;
}
}
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
'candidates' => $candidates,
]);
}
private function ensureWritableDirectory(string $directory): bool
{
if (is_dir($directory)) {
return is_writable($directory);
}
return @mkdir($directory, 0775, true) || is_dir($directory);
}
private function extractRawText(string $imagePath): array
{
$ocrConfig = $this->config->ocr();
$providers = $ocrConfig['providers'] ?? ['tesseract'];
$flags = [];
if (!is_array($providers) || $providers === []) {
$providers = ['tesseract'];
}
foreach ($providers as $provider) {
$providerName = strtolower(trim((string) $provider));
if ($providerName === '') {
continue;
}
if ($providerName === 'ocrspace') {
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
} elseif ($providerName === 'tesseract') {
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
} else {
$flags[] = 'ocr_provider_unsupported:' . $providerName;
continue;
}
$flags = array_merge($flags, $result['flags']);
if (($result['text'] ?? '') !== '') {
return [
'text' => (string) $result['text'],
'flags' => array_values(array_unique(array_merge(
$flags,
['ocr_provider:' . $providerName]
))),
];
}
}
return [
'text' => '',
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
];
}
private function runOcrSpace(array $providerConfig, string $imagePath): array
{
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
];
}
$url = trim((string) ($providerConfig['url'] ?? ''));
if ($url === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
];
}
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
if ($apiKey === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
];
}
$postFields = [
'file' => new \CURLFile($imagePath),
'language' => (string) ($providerConfig['language'] ?? 'eng'),
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
'isOverlayRequired' => 'false',
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $curlError !== '') {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
];
}
$flags = [];
$rawText = '';
$parsedResults = $payload['ParsedResults'] ?? null;
if (is_array($parsedResults)) {
$texts = [];
foreach ($parsedResults as $result) {
if (!is_array($result)) {
continue;
}
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
if ($fileExitCode !== '') {
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
}
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
if ($parsedText !== '') {
$texts[] = $parsedText;
}
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
if ($resultError !== '') {
$flags[] = 'ocrspace_result_error';
}
}
$rawText = trim(implode("\n", $texts));
}
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
if ($httpStatus >= 400) {
$flags[] = 'ocrspace_http_error';
}
if ($ocrExitCode !== '') {
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
}
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
if ($isErroredOnProcessing) {
$flags[] = 'ocrspace_processing_error';
}
if ($errorMessage !== '' || $errorDetails !== '') {
$flags[] = 'ocrspace_error';
}
return [
'text' => $rawText,
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
];
}
private function runTesseract(array $providerConfig, string $imagePath): array
{
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
if (!$this->binaryExists($binary)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:tesseract'],
];
}
$language = (string) ($providerConfig['language'] ?? 'eng');
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
if ($tmpBase === false) {
return [
'text' => '',
'flags' => ['ocr_tempfile_failed:tesseract'],
];
}
@unlink($tmpBase);
$command = sprintf(
'%s %s %s -l %s 2>/dev/null',
escapeshellcmd($binary),
escapeshellarg($imagePath),
escapeshellarg($tmpBase),
escapeshellarg($language)
);
shell_exec($command);
$txtFile = $tmpBase . '.txt';
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
@unlink($txtFile);
return [
'text' => trim($text),
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
];
}
private function binaryExists(string $binary): bool
{
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
}
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
{
$measurement = $this->parseMeasurementText($rawText, $dateContext);
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
&& (
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
);
return [
'kind' => $isWallet ? 'wallet' : 'measurement',
'suggested' => $measurement['suggested'],
'suggested_wallet' => $wallet['suggested_wallet'],
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
];
}
private function parseMeasurementText(string $rawText, string $dateContext): array
{
$flags = [];
$suggestedTime = null;
$coinsTotal = null;
$price = null;
$currency = null;
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
$lines = array_values(array_filter(array_map(
static fn (string $line): string => trim($line),
preg_split('/\R/u', $rawText) ?: []
), static fn (string $line): bool => $line !== ''));
if ($normalizedText === '') {
$flags[] = 'ocr_raw_text_empty';
}
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
$decimalCandidates = [];
foreach ($numberMatches[0] ?? [] as $candidate) {
$normalized = (float) str_replace(',', '.', $candidate);
if ($normalized <= 0) {
continue;
}
$decimalCandidates[] = [
'raw' => $candidate,
'value' => $normalized,
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
: 0,
];
}
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
$currency = strtoupper((string) $pairMatch[1]);
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
$currency = strtoupper((string) $currencyMatch[1]);
} elseif (str_contains($normalizedText, '$')) {
$currency = 'USD';
} else {
$flags[] = 'currency_missing';
}
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
}
if ($coinsTotal === null) {
foreach ($lines as $line) {
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
continue;
}
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
$flags[] = 'coins_from_balance_line';
break;
}
}
}
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
$flags[] = 'coins_from_balance_context';
}
if ($coinsTotal === null) {
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
if ($item['precision'] < 4) {
return false;
}
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
return false;
}
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
return false;
}
return true;
}));
if ($coinsCandidates !== []) {
usort($coinsCandidates, static function (array $a, array $b): int {
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
});
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
if (count($coinsCandidates) > 1) {
$flags[] = 'coins_ambiguous';
}
} else {
$flags[] = 'coins_missing';
}
}
$priceCandidates = array_values(array_filter(
$decimalCandidates,
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
));
if ($price === null && $priceCandidates !== []) {
usort($priceCandidates, static function (array $a, array $b): int {
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
});
$price = round((float) $priceCandidates[0]['value'], 8);
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
$flags[] = 'price_ambiguous';
}
}
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
if ($fiatValue > 0) {
$price = round($fiatValue / $coinsTotal, 8);
$flags[] = 'price_derived_from_balance_value';
$currency = $currency ?? 'USD';
}
}
$measurementIndicators = 0;
$normalizedLower = strtolower($normalizedText);
foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) {
if (str_contains($normalizedLower, $indicator)) {
$measurementIndicators++;
}
}
$matchedFields = 0;
foreach ([$coinsTotal, $price, $currency] as $field) {
if ($field !== null) {
$matchedFields++;
}
}
$score = $matchedFields + min(3, $measurementIndicators);
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04)));
return [
'suggested' => [
'measured_at' => $suggestedTime,
'coins_total' => $coinsTotal,
'price_per_coin' => $price,
'price_currency' => $currency,
'note' => null,
'source' => 'image_ocr',
],
'confidence' => round($confidence, 4),
'flags' => $flags,
'score' => $score,
];
}
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
{
$flags = [];
$suggestedTime = null;
$totalValueAmount = null;
$totalValueCurrency = null;
$walletBalance = null;
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
$balances = [];
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
$lines = array_values(array_filter(array_map(
static fn (string $line): string => trim($line),
preg_split('/\R/u', $rawText) ?: []
), static fn (string $line): bool => $line !== ''));
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
if (
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
) {
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
} else {
$flags[] = 'wallet_total_missing';
}
$assetRows = [];
foreach ($lines as $lineIndex => $line) {
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
continue;
}
$amount = round((float) str_replace(',', '.', $match[1]), 10);
$currency = strtoupper((string) $match[2]);
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
continue;
}
$assetRows[] = [
'index' => $lineIndex,
'currency' => $currency,
'balance' => $amount,
];
}
foreach ($assetRows as $assetIndex => $assetRow) {
$currency = (string) $assetRow['currency'];
$balanceAmount = (float) $assetRow['balance'];
$startIndex = (int) $assetRow['index'];
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
? (int) $assetRows[$assetIndex + 1]['index']
: count($lines);
$usdCandidates = [];
for ($i = $startIndex; $i < $endIndex; $i++) {
if (!isset($lines[$i])) {
continue;
}
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
foreach ($usdMatches as $usdMatch) {
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
}
}
}
$priceAmount = $this->pickWalletUnitPrice(
$balanceAmount,
$usdCandidates,
$totalValueCurrency === 'USD' ? $totalValueAmount : null
);
$balances[$currency] = [
'balance' => $balanceAmount,
'price_amount' => $priceAmount,
'price_currency' => $priceAmount !== null ? 'USD' : null,
];
}
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
$walletCurrency = $walletCurrencyHint;
$walletBalance = is_array($balances[$walletCurrencyHint])
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
: (float) $balances[$walletCurrencyHint];
} elseif ($balances !== []) {
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
if (array_key_exists($preferredCurrency, $balances)) {
$walletCurrency = $preferredCurrency;
$walletBalance = is_array($balances[$preferredCurrency])
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
: (float) $balances[$preferredCurrency];
break;
}
}
if ($walletBalance === null) {
$firstCurrency = array_key_first($balances);
if (is_string($firstCurrency)) {
$walletCurrency = $firstCurrency;
$walletBalance = is_array($balances[$firstCurrency])
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
: (float) $balances[$firstCurrency];
}
}
} else {
$flags[] = 'wallet_balance_missing';
}
$walletIndicators = 0;
$normalizedLower = strtolower($normalizedText);
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
if (str_contains($normalizedLower, $indicator)) {
$walletIndicators++;
}
}
$matchedFields = 0;
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
if ($field !== null && $field !== '') {
$matchedFields++;
}
}
$score = $matchedFields + ($walletIndicators * 2);
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
ksort($balances);
return [
'suggested_wallet' => [
'measured_at' => $suggestedTime,
'total_value_amount' => $totalValueAmount,
'total_value_currency' => $totalValueCurrency,
'wallet_balance' => $walletBalance,
'wallet_currency' => $walletCurrency,
'balances_json' => $balances,
'note' => null,
'source' => 'image_ocr',
],
'confidence' => round($confidence, 4),
'flags' => array_values(array_unique($flags)),
'score' => $score,
];
}
/**
* @param list<float|int> $candidates
*/
private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?float
{
$candidates = array_values(array_filter(array_map(
static fn (mixed $value): float => round((float) $value, 8),
$candidates
), static fn (float $value): bool => $value > 0));
if ($balance <= 0 || $candidates === []) {
return null;
}
if (count($candidates) === 1) {
$candidate = $candidates[0];
if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) {
return round($candidate / $balance, 8);
}
return $candidate;
}
$bestPrice = null;
$bestError = null;
$candidateCount = count($candidates);
for ($i = 0; $i < $candidateCount; $i++) {
$priceCandidate = $candidates[$i];
for ($j = 0; $j < $candidateCount; $j++) {
if ($i === $j) {
continue;
}
$totalCandidate = $candidates[$j];
$estimatedTotal = $balance * $priceCandidate;
$denominator = max(abs($totalCandidate), 0.00000001);
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
if ($bestError === null || $error < $bestError) {
$bestError = $error;
$bestPrice = $priceCandidate;
}
}
}
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
return round($bestPrice, 8);
}
return round($candidates[count($candidates) - 1], 8);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
final class SeedData
{
public static function projectKey(): string
{
return 'doge-main';
}
public static function projectName(): string
{
return 'DOGE Mining Main';
}
public static function settings(): array
{
return [
'baseline_measured_at' => '2026-03-16 01:32:00',
'baseline_coins_total' => 27.617864,
'daily_cost_amount' => 0.3123287671,
'daily_cost_currency' => 'EUR',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
];
}
public static function currencies(): array
{
return [
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
];
}
public static function measurements(): array
{
return [
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
];
}
public static function targets(): array
{
return [
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
];
}
public static function dashboards(): array
{
return [
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
final class SeedImporter
{
private MiningRepository $repository;
public function __construct(MiningRepository $repository)
{
$this->repository = $repository;
}
public function import(string $projectKey): array
{
$seedProjectKey = SeedData::projectKey();
if ($projectKey !== $seedProjectKey) {
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
}
$this->repository->ensureProject($projectKey, SeedData::projectName());
$this->repository->saveSettings($projectKey, SeedData::settings());
$insertedMeasurements = 0;
foreach (SeedData::measurements() as $measurement) {
try {
$this->repository->createMeasurement($projectKey, array_merge([
'image_path' => null,
'ocr_raw_text' => null,
'ocr_confidence' => null,
'ocr_flags' => null,
], $measurement));
$insertedMeasurements++;
} catch (\Throwable $exception) {
// Duplicate seeds are expected on repeated imports.
}
}
$targetCount = 0;
foreach (SeedData::targets() as $target) {
$this->repository->saveTarget($projectKey, $target);
$targetCount++;
}
$dashboardCount = 0;
foreach (SeedData::dashboards() as $dashboard) {
$this->repository->saveDashboard($projectKey, $dashboard);
$dashboardCount++;
}
return [
'project_key' => $projectKey,
'imported_measurements' => $insertedMeasurements,
'historical_rows_total' => count(SeedData::measurements()),
'targets_synced' => $targetCount,
'dashboards_synced' => $dashboardCount,
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use App\PdoFactory;
use Modules\MiningChecker\Support\ApiException;
use PDO;
final class ConnectionFactory
{
public static function make(ModuleConfig $config): PDO
{
$moduleSettings = modules()->settings('mining-checker');
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
if ($useSeparateDb) {
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
if ($dbConfig === []) {
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
}
self::assertSupportedDriver($dbConfig);
return PdoFactory::createFromArray($dbConfig);
}
$dbConfig = app()->config()->dbConfig;
if ($dbConfig === []) {
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
}
self::assertSupportedDriver($dbConfig);
return PdoFactory::createFromArray($dbConfig);
}
private static function usesSeparateDatabase(array $moduleSettings): bool
{
$raw = $moduleSettings['use_separate_db'] ?? false;
if (is_bool($raw)) {
return $raw;
}
$normalized = strtolower(trim((string) $raw));
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
}
private static function assertSupportedDriver(array $dbConfig): void
{
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
throw new ApiException(
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
500,
['driver' => $dbConfig['driver'] ?? 'unknown']
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
final class ModuleConfig
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
public static function load(string $moduleBasePath): self
{
$config = require $moduleBasePath . '/config/module.php';
return new self(is_array($config) ? $config : []);
}
public function defaultProjectKey(): string
{
return (string) ($this->config['default_project_key'] ?? 'doge-main');
}
public function useProjectDatabase(): bool
{
return (bool) ($this->config['use_project_database'] ?? true);
}
public function tablePrefix(): string
{
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
}
public function uploadsDir(): string
{
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
}
public function uploadsPublicPrefix(): string
{
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
}
public function ocr(): array
{
return (array) ($this->config['ocr'] ?? []);
}
public function fx(): array
{
return (array) ($this->config['fx'] ?? []);
}
public function debug(): array
{
return (array) ($this->config['debug'] ?? []);
}
public function debugDir(): string
{
$debug = $this->debug();
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Legacy;
use RuntimeException;
final class LegacyModuleStore
{
public function __construct(
private readonly string $projectRoot,
private readonly string $userScope,
) {
}
/**
* @return array<string, mixed>
*/
public function loadProject(string $projectKey): array
{
$state = $this->loadAll();
$projects = is_array($state['projects'] ?? null) ? $state['projects'] : [];
if (!isset($projects[$projectKey]) || !is_array($projects[$projectKey])) {
$projects[$projectKey] = $this->defaultProjectState($projectKey);
$state['projects'] = $projects;
$this->saveAll($state);
}
return array_replace_recursive($this->defaultProjectState($projectKey), $projects[$projectKey]);
}
/**
* @param array<string, mixed> $projectState
*/
public function saveProject(string $projectKey, array $projectState): void
{
$state = $this->loadAll();
$projects = is_array($state['projects'] ?? null) ? $state['projects'] : [];
$projects[$projectKey] = array_replace_recursive($this->defaultProjectState($projectKey), $projectState);
$state['projects'] = $projects;
$this->saveAll($state);
}
/**
* @return array<string, mixed>
*/
public function loadAuth(): array
{
$state = $this->loadAll();
$auth = is_array($state['module_auth'] ?? null) ? $state['module_auth'] : [];
return array_replace_recursive([
'required' => true,
'users' => [],
'groups' => [],
], $auth);
}
/**
* @param array<string, mixed> $auth
*/
public function saveAuth(array $auth): void
{
$state = $this->loadAll();
$state['module_auth'] = [
'required' => (bool) ($auth['required'] ?? true),
'users' => array_values(array_filter(array_map('strval', (array) ($auth['users'] ?? [])))),
'groups' => array_values(array_filter(array_map('strval', (array) ($auth['groups'] ?? [])))),
];
$this->saveAll($state);
}
public function nextId(string $projectKey, string $bucket): int
{
$project = $this->loadProject($projectKey);
$counters = is_array($project['_counters'] ?? null) ? $project['_counters'] : [];
$next = (int) ($counters[$bucket] ?? 0) + 1;
$counters[$bucket] = $next;
$project['_counters'] = $counters;
$this->saveProject($projectKey, $project);
return $next;
}
/**
* @return array<string, mixed>
*/
private function loadAll(): array
{
$path = $this->path();
if (!is_file($path)) {
return [
'module_auth' => [
'required' => true,
'users' => [],
'groups' => [],
],
'projects' => [],
];
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return [
'module_auth' => [
'required' => true,
'users' => [],
'groups' => [],
],
'projects' => [],
];
}
$data = json_decode($raw, true);
return is_array($data) ? $data : [
'module_auth' => [
'required' => true,
'users' => [],
'groups' => [],
],
'projects' => [],
];
}
/**
* @param array<string, mixed> $state
*/
private function saveAll(array $state): void
{
$directory = dirname($this->path());
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Mining-Checker Speicherverzeichnis konnte nicht erstellt werden.');
}
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht serialisiert werden.');
}
if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) {
throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht geschrieben werden.');
}
}
private function path(): string
{
$safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest';
return $this->projectRoot . '/data/mining-checker/users/' . $safeScope . '.json';
}
/**
* @return array<string, mixed>
*/
private function defaultProjectState(string $projectKey): array
{
return [
'_counters' => [],
'project' => [
'project_key' => $projectKey,
'project_name' => strtoupper(str_replace('-', ' ', $projectKey)),
],
'settings' => [
'baseline_measured_at' => gmdate('Y-m-d H:i:s'),
'baseline_coins_total' => 0,
'daily_cost_amount' => 0,
'daily_cost_currency' => 'EUR',
'report_currency' => 'EUR',
'crypto_currency' => 'DOGE',
'display_timezone' => 'Europe/Berlin',
'fx_max_age_hours' => 3,
'module_theme_mode' => 'inherit',
'module_theme_accent' => 'teal',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
],
'measurements' => [],
'targets' => [],
'dashboards' => [],
'wallet_snapshots' => [],
'cost_plans' => [],
'payouts' => [],
'miner_offers' => [],
'purchased_miners' => [],
'fx_history' => [],
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Legacy;
use ModulesCore\ModuleHttp;
final class UserScope
{
public static function current(): string
{
return ModuleHttp::currentUserScope();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
use RuntimeException;
final class ApiException extends RuntimeException
{
private int $statusCode;
private array $context;
public function __construct(string $message, int $statusCode = 400, array $context = [])
{
parent::__construct($message);
$this->statusCode = $statusCode;
$this->context = $context;
}
public function statusCode(): int
{
return $this->statusCode;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugState
{
private static array $trace = [];
private static ?string $latestFilePath = null;
public static function replace(array $trace): void
{
self::$trace = $trace;
}
public static function export(): array
{
return self::$trace;
}
public static function clear(): void
{
self::$trace = [];
}
public static function setLatestFilePath(?string $filePath): void
{
self::$latestFilePath = $filePath;
}
public static function latestFilePath(): ?string
{
return self::$latestFilePath;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugTrace
{
private bool $enabled;
private array $entries = [];
private ?string $filePath;
public function __construct(bool $enabled = false, ?string $filePath = null)
{
$this->enabled = $enabled;
$this->filePath = $enabled ? $filePath : null;
DebugState::replace([]);
if ($this->enabled && $this->filePath !== null) {
$this->persist();
}
}
public function enabled(): bool
{
return $this->enabled;
}
public function add(string $event, array $context = []): void
{
if (!$this->enabled) {
return;
}
$this->entries[] = [
'time' => date('c'),
'event' => $event,
'context' => $context,
];
DebugState::replace($this->entries);
$this->persist();
}
public function export(): array
{
return $this->enabled ? $this->entries : [];
}
private function persist(): void
{
if (!$this->enabled || $this->filePath === null) {
return;
}
$directory = dirname($this->filePath);
if (!is_dir($directory)) {
@mkdir($directory, 0775, true);
}
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class Http
{
public static function json(array $payload, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function input(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '[]', true);
return is_array($data) ? $data : [];
}
return $_POST;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
(function () {
'use strict';
function toPartialUrl(input, fallbackView) {
const url = new URL(input, window.location.origin);
if (!url.searchParams.has('view') && fallbackView) {
url.searchParams.set('view', fallbackView);
}
url.searchParams.set('partial', '1');
return url;
}
async function fetchHtml(url, options) {
const response = await fetch(url.toString(), {
credentials: 'same-origin',
headers: {
Accept: 'text/html',
'X-Requested-With': 'XMLHttpRequest'
},
...options
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
function enhanceHost(host, state) {
host.querySelectorAll('a[href]').forEach((link) => {
const href = link.getAttribute('href') || '';
if (!href.startsWith('/apps/pihole')) {
return;
}
link.addEventListener('click', (event) => {
event.preventDefault();
state.load(href);
});
});
host.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const method = String(form.getAttribute('method') || 'get').toUpperCase();
const action = form.getAttribute('action') || state.currentUrl.toString();
const formData = new FormData(form);
const url = toPartialUrl(action, state.defaultView);
try {
if (method === 'GET') {
const next = new URL(url.toString());
next.search = '';
next.searchParams.set('partial', '1');
for (const [key, value] of formData.entries()) {
next.searchParams.append(key, String(value));
}
await state.load(next.toString());
return;
}
const html = await fetchHtml(url, {
method,
body: formData
});
state.render(html, url);
} catch (error) {
state.renderError(error);
}
});
});
}
function initContent(host) {
if (typeof window.initPiholeModule === 'function') {
window.initPiholeModule(host);
}
if (typeof window.initPiholeInstancesModule === 'function') {
window.initPiholeInstancesModule(host);
}
}
window.initPiholeApp = function initPiholeApp(host, options) {
if (!(host instanceof HTMLElement)) {
return;
}
const entryRoute = String(options?.entryRoute || '/apps/pihole/index.php');
const defaultView = String(options?.defaultView || 'dashboard');
const state = {
defaultView,
currentUrl: toPartialUrl(entryRoute, defaultView),
async load(targetUrl) {
const url = toPartialUrl(targetUrl, defaultView);
this.currentUrl = url;
host.innerHTML = '<div class="window-app-loading"><p>Pi-hole wird geladen...</p></div>';
try {
const html = await fetchHtml(url);
this.render(html, url);
} catch (error) {
this.renderError(error);
}
},
render(html, url) {
this.currentUrl = url;
host.innerHTML = html;
enhanceHost(host, this);
initContent(host);
},
renderError(error) {
const message = error instanceof Error ? error.message : 'Pi-hole konnte nicht geladen werden.';
host.innerHTML = `<div class="window-app-error-state"><p>${message}</p></div>`;
}
};
state.load(entryRoute);
};
})();

View File

@@ -0,0 +1,419 @@
.pihole-frame {
height: 100%;
min-height: 0;
}
.pihole-panel-shell {
max-width: 1280px;
}
.pihole-content {
display: grid;
gap: 18px;
}
.pihole-card,
.module-box,
.module-box-soft {
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 22px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
padding: 20px;
}
.pihole-message {
padding: 14px 16px;
border-radius: 16px;
}
.pihole-message--error {
color: #991b1b;
background: rgba(254, 226, 226, 0.9);
border: 1px solid rgba(248, 113, 113, 0.3);
}
.pihole-message--success {
color: #166534;
background: rgba(220, 252, 231, 0.9);
border: 1px solid rgba(74, 222, 128, 0.3);
}
.pihole-grid,
.module-box-grid--stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.pihole-stat {
padding: 16px;
}
.pihole-stat-value {
font-size: 1.6rem;
font-weight: 700;
margin-top: 6px;
}
.pihole-stat-sub {
margin-top: 4px;
color: #475569;
}
.pihole-section-header,
.module-box-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.pihole-section-title,
.module-box-title {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
}
.pihole-actions,
.pihole-card-actions,
.pihole-modal-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.pihole-primary-action,
.cta-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 16px;
border: 0;
border-radius: 14px;
background: linear-gradient(135deg, #14b8a6, #0f766e);
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: none;
}
.pihole-link-button,
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 8px 14px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.pihole-inline {
display: inline-flex;
gap: 8px;
align-items: center;
}
.pihole-inline input {
width: 110px;
}
.pihole-instance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
margin-top: 12px;
}
.pihole-instance,
.pihole-instance-card {
display: grid;
gap: 12px;
}
.pihole-instance-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.pihole-instance-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 12px;
}
.pihole-instance-value {
font-weight: 700;
margin-top: 4px;
}
.pihole-status {
padding: 6px 10px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
background: rgba(20, 184, 166, 0.15);
color: #0f766e;
}
.pihole-status.is-disabled {
background: rgba(239, 68, 68, 0.15);
color: #b91c1c;
}
.pihole-status.is-partial {
background: rgba(245, 158, 11, 0.18);
color: #92400e;
}
.pihole-split,
.module-box-grid--panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.pihole-list,
.pihole-blocked {
display: grid;
gap: 8px;
margin-top: 12px;
}
.pihole-list-row,
.pihole-blocked-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(248, 250, 252, 0.95);
}
.pihole-blocked-row {
background: rgba(254, 242, 242, 0.9);
}
.pihole-update {
margin-top: 10px;
font-size: 0.95rem;
color: #475569;
}
.pihole-error {
margin-top: 8px;
font-size: 0.9rem;
color: #b91c1c;
}
.pihole-form,
.pihole-setup-form {
display: grid;
gap: 12px;
margin-top: 12px;
}
.pihole-form {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: end;
}
.pihole-form label,
.pihole-field {
display: grid;
gap: 6px;
}
.pihole-field input,
.pihole-field select,
.pihole-form input,
.pihole-form select,
.pihole-form textarea,
.pihole-instance-form input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 14px;
background: #fff;
color: #0f172a;
font: inherit;
}
.pihole-setup-grid,
.pihole-instance-form-grid,
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.pihole-form-field-wide {
grid-column: 1 / -1;
}
.pihole-checkbox-field {
display: inline-flex;
align-items: center;
gap: 10px;
}
.pihole-checkbox-field input {
width: 18px;
min-height: 18px;
}
.pihole-page.is-busy {
position: relative;
}
.modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 23, 42, 0.24);
z-index: 90;
}
.modal.is-open,
.modal[aria-hidden="false"] {
display: flex;
}
.modal-card {
width: min(720px, 96vw);
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18);
padding: 20px;
color: #0f172a;
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.pihole-console-heading {
display: grid;
gap: 6px;
}
.pihole-console-heading strong {
font-size: 1.05rem;
color: #0f172a;
}
.icon-button {
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: #fff;
color: #0f172a;
min-width: 110px;
height: 40px;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.pihole-console-body {
margin-top: 16px;
display: grid;
gap: 10px;
max-height: 48vh;
overflow: auto;
padding: 4px;
}
.pihole-console-line {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(255, 255, 255, 0.75);
color: #0f172a;
}
.pihole-console-meta {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.pihole-console-line span {
font-size: 0.8rem;
color: #475569;
}
.pihole-console-line strong {
font-weight: 600;
color: #0f172a;
}
.pihole-console-detail {
font-size: 0.92rem;
color: #334155;
}
.pihole-console-line.is-success {
border-color: rgba(20, 184, 166, 0.22);
background: rgba(20, 184, 166, 0.08);
}
.pihole-console-line.is-error {
border-color: rgba(239, 68, 68, 0.24);
background: rgba(239, 68, 68, 0.08);
}
.pihole-test-result {
margin-top: 6px;
font-size: 0.9rem;
color: #475569;
}
.pihole-test-result.is-ok { color: #0f766e; }
.pihole-test-result.is-auth,
.pihole-test-result.is-unreachable,
.pihole-test-result.is-error { color: #b91c1c; }
.pihole-test-result.is-invalid { color: #92400e; }
@media (max-width: 980px) {
.pihole-frame {
grid-template-columns: 1fr;
}
.pihole-actions,
.pihole-card-actions,
.pihole-modal-actions,
.pihole-inline {
flex-direction: column;
align-items: stretch;
}
.pihole-inline input {
width: 100%;
}
}

View File

@@ -0,0 +1,488 @@
(function () {
'use strict';
window.initPiholeModule = function initPiholeModule(rootNode) {
const scope = rootNode instanceof Element ? rootNode : document;
const page = scope.matches?.('[data-pihole-page]') ? scope : scope.querySelector('[data-pihole-page]');
if (!page || page.dataset.piholeBound === '1') return;
page.dataset.piholeBound = '1';
const pageType = page.dataset.piholePage || 'dashboard';
const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0);
const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5;
const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0
? configuredRefreshSeconds
: defaultRefreshSeconds;
const fmt = new Intl.NumberFormat('de-DE');
const fmtDate = (ts) => {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleString('de-DE');
};
let refreshTimer = null;
let loadInFlight = false;
let actionInFlight = false;
let actionConsoleApi = null;
let actionConsoleBody = null;
let actionConsoleClose = null;
let actionConsoleTitle = null;
let actionConsoleSubtitle = null;
const apiCall = async (action, payload = {}) => {
const res = await fetch(`/api/pihole/index.php?action=${encodeURIComponent(action)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
if (data && data.results && typeof data.results === 'object') {
const failures = Object.values(data.results).filter((row) => row && row.ok === false);
if (failures.length) {
const first = failures[0];
throw new Error(first.error || 'action_failed');
}
}
return data;
};
const query = (selector) => page.querySelector(selector);
const setText = (el, value) => {
if (el) el.textContent = value;
};
const ensureActionConsole = () => {
if (actionConsoleApi && actionConsoleBody && actionConsoleClose) return;
const root = document.createElement('div');
root.className = 'modal';
root.dataset.piholeConsoleModal = '1';
root.setAttribute('aria-hidden', 'true');
root.innerHTML = `
<div class="modal-card pihole-console-modal" role="dialog" aria-modal="true" aria-labelledby="pihole-console-title">
<div class="modal-header">
<div class="pihole-console-heading">
<strong id="pihole-console-title">Pi-hole Aktion</strong>
<div class="muted" data-pihole-console-subtitle>Status und Rueckmeldungen zur laufenden Aktion.</div>
</div>
<div class="pihole-card-actions">
<button class="pihole-link-button" type="button" data-pihole-console-clear>Protokoll leeren</button>
<button class="icon-button" type="button" data-pihole-console-close aria-label="Fenster schliessen">Schliessen</button>
</div>
</div>
<div class="pihole-console-body" data-pihole-console-body></div>
</div>
`;
document.body.appendChild(root);
actionConsoleTitle = root.querySelector('#pihole-console-title');
actionConsoleSubtitle = root.querySelector('[data-pihole-console-subtitle]');
actionConsoleBody = root.querySelector('[data-pihole-console-body]');
actionConsoleClose = root.querySelector('[data-pihole-console-close]');
const clearBtn = root.querySelector('[data-pihole-console-clear]');
actionConsoleApi = {
open() {
root.classList.add('is-open');
root.setAttribute('aria-hidden', 'false');
},
close() {
root.classList.remove('is-open');
root.setAttribute('aria-hidden', 'true');
}
};
actionConsoleClose?.addEventListener('click', () => {
if (!actionInFlight) actionConsoleApi.close();
});
clearBtn?.addEventListener('click', () => {
if (actionConsoleBody) actionConsoleBody.innerHTML = '';
});
};
const setActionConsoleMeta = (title, subtitle) => {
ensureActionConsole();
if (actionConsoleTitle) actionConsoleTitle.textContent = title || 'Pi-hole Aktion';
if (actionConsoleSubtitle) actionConsoleSubtitle.textContent = subtitle || 'Status und Rueckmeldungen zur laufenden Aktion.';
};
const appendActionLog = (message, tone = 'info', detail = '') => {
ensureActionConsole();
if (!actionConsoleBody) return;
const row = document.createElement('div');
row.className = `pihole-console-line is-${tone}`;
const timestamp = new Date().toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
row.innerHTML = `
<div class="pihole-console-meta">
<span>${timestamp}</span>
<span>${tone === 'success' ? 'Erfolg' : tone === 'error' ? 'Fehler' : 'Info'}</span>
</div>
<strong>${message}</strong>
${detail ? `<div class="pihole-console-detail">${detail}</div>` : ''}
`;
actionConsoleBody.appendChild(row);
actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight;
};
const setActionLock = (locked) => {
actionInFlight = locked;
page.classList.toggle('is-busy', locked);
ensureActionConsole();
if (actionConsoleClose) {
actionConsoleClose.disabled = locked;
}
page.querySelectorAll('button, input, select, textarea').forEach((formControl) => {
if (locked) {
formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false';
formControl.disabled = true;
return;
}
formControl.disabled = formControl.dataset.piholeWasDisabled === 'true';
delete formControl.dataset.piholeWasDisabled;
});
};
const statusLabel = (status) => {
if (status === 'enabled') return 'Aktiv';
if (status === 'disabled') return 'Deaktiviert';
if (status === 'partial') return 'Teilweise';
return 'Unbekannt';
};
const actionLabel = (action) => {
if (action === 'enable') return 'Pi-hole aktivieren';
if (action === 'disable' || action === 'disable-custom') return 'Pi-hole temporaer deaktivieren';
if (action === 'gravity') return 'Listen aktualisieren';
if (action === 'update') return 'Pi-hole aktualisieren';
return 'Pi-hole Aktion';
};
const targetLabel = (instance) => {
if (!instance || instance === 'all') return 'alle Instanzen';
if (instance === 'primary') return 'die Primaer-Instanz';
return `Instanz ${instance}`;
};
const setStatusBadge = (el, status) => {
if (!el) return;
el.textContent = statusLabel(status);
el.classList.remove('is-disabled', 'is-partial');
if (status === 'disabled') el.classList.add('is-disabled');
if (status === 'partial') el.classList.add('is-partial');
};
const mapToList = (map, limit = 10) => {
if (!map || typeof map !== 'object') return [];
return Object.entries(map)
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
.map(([key, value]) => [key, Number(value)])
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
};
const renderList = (container, map, emptyText) => {
if (!container) return;
const rows = mapToList(map, 10);
container.innerHTML = '';
if (!rows.length) {
const div = document.createElement('div');
div.className = 'muted';
div.textContent = emptyText || 'Keine Daten';
container.appendChild(div);
return;
}
rows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'pihole-list-row';
row.innerHTML = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
container.appendChild(row);
});
};
const renderBlocked = (container, list) => {
if (!container) return;
container.innerHTML = '';
if (!Array.isArray(list) || !list.length) {
const div = document.createElement('div');
div.className = 'muted';
div.textContent = 'Keine Daten';
container.appendChild(div);
return;
}
list.slice(0, 20).forEach((entry) => {
const domain = entry.domain || entry;
const instance = entry.instance || '';
const row = document.createElement('div');
row.className = 'pihole-blocked-row';
row.innerHTML = `<span>${domain}</span><span class="muted">${instance}</span>`;
container.appendChild(row);
});
};
const renderInstances = (instances) => {
const holder = query('[data-instance-cards]');
const tpl = scope.querySelector('#pihole-instance-template');
if (!holder || !tpl) return;
holder.innerHTML = '';
Object.values(instances).forEach((entry) => {
const node = tpl.content.cloneNode(true);
const root = node.querySelector('[data-instance]');
if (!root) return;
root.dataset.instance = entry.meta.id;
const summary = entry.summary || {};
setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
setText(root.querySelector('[data-instance-percent]'), `${Number(summary.ads_percentage_today || summary.ads_percentage || 0).toFixed(2)}%`);
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
root.querySelectorAll('[data-instance]').forEach((btn) => {
if (btn.dataset.instance === '') btn.dataset.instance = entry.meta.id;
});
const customInput = root.querySelector('[data-custom-minutes]');
if (customInput) customInput.dataset.customMinutes = entry.meta.id;
const updateEl = root.querySelector('[data-instance-update]');
if (updateEl) {
updateEl.textContent = entry.updates && entry.updates.available ? 'Updates verfuegbar' : 'Keine Updates erkannt';
}
const errorEl = root.querySelector('[data-instance-errors]');
if (errorEl) {
if (Array.isArray(entry.errors) && entry.errors.length) {
const lines = entry.errors.map((err) => {
const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?';
const scopeName = err.scope || 'request';
const msg = err.error || 'error';
const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : '';
return `${scopeName}: ${msg} (${code}${hint})`;
});
errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
} else {
errorEl.textContent = '';
}
}
holder.appendChild(node);
});
};
const renderDashboardData = (data) => {
const summary = data.aggregate?.summary || {};
setText(query('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
setText(query('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
setText(query('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
setText(query('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
setStatusBadge(query('[data-summary-status]'), summary.status || 'unknown');
setText(query('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
renderInstances(data.instances || {});
renderList(query('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
renderList(query('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
renderList(query('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
renderList(query('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
renderList(query('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
renderBlocked(query('[data-recent-blocked]'), data.aggregate?.recent_blocked);
};
const dashboardFingerprint = (data) => JSON.stringify({
blocked_domains: Number(data?.aggregate?.summary?.blocked_domains || 0),
ads_blocked_today: Number(data?.aggregate?.summary?.ads_blocked_today || 0),
unique_domains: Number(data?.aggregate?.summary?.unique_domains || 0),
instances: Object.values(data?.instances || {}).map((entry) => ({
id: entry?.meta?.id || '',
blocked_domains: Number(entry?.summary?.blocked_domains || 0),
ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0),
unique_domains: Number(entry?.summary?.unique_domains || 0),
})),
});
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const monitorGravityProgress = async (baselineData, instanceLabel) => {
const baselineFingerprint = dashboardFingerprint(baselineData || {});
const maxPolls = 24;
const pollDelayMs = 5000;
appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info');
for (let attempt = 1; attempt <= maxPolls; attempt += 1) {
await delay(pollDelayMs);
appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info');
try {
const data = await apiCall('dashboard');
if (!data.ok) throw new Error(data.error || 'API error');
renderDashboardData(data);
if (dashboardFingerprint(data) !== baselineFingerprint) {
appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success');
return true;
}
} catch (err) {
appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error');
}
}
appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden.`, 'error');
return false;
};
const loadDashboard = async () => {
if (loadInFlight || actionInFlight) return;
loadInFlight = true;
try {
const data = await apiCall('dashboard');
if (!data.ok) throw new Error(data.error || 'API error');
renderDashboardData(data);
query('[data-pihole-load-error]')?.remove();
} catch (err) {
let message = query('[data-pihole-load-error]');
if (!message) {
message = document.createElement('div');
message.className = 'window-app-card pihole-card';
message.style.marginTop = '1rem';
message.dataset.piholeLoadError = '1';
page.appendChild(message);
}
message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
} finally {
loadInFlight = false;
}
};
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const instance = btn.dataset.instance || 'all';
const minutes = btn.dataset.minutes;
const buttonScope = btn.closest('.pihole-instance') || page;
const customInput = buttonScope.querySelector(`[data-custom-minutes="${instance}"]`);
const payload = { instance };
let baselineData = null;
if (action === 'disable') payload.minutes = Number(minutes || 0);
if (action === 'disable-custom') payload.minutes = Number(customInput?.value || 0);
try {
ensureActionConsole();
if (actionConsoleBody) actionConsoleBody.innerHTML = '';
setActionConsoleMeta(actionLabel(action), `Rueckmeldungen fuer ${targetLabel(instance)}.`);
actionConsoleApi.open();
setActionLock(true);
baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null;
if (action === 'enable') {
appendActionLog('Aktivierung wird ausgefuehrt.', 'info', `Ziel: ${targetLabel(instance)}`);
await apiCall('enable', payload);
} else if (action === 'disable' || action === 'disable-custom') {
if (!payload.minutes || payload.minutes <= 0) {
appendActionLog('Fehler: Bitte Minuten angeben.', 'error');
return;
}
appendActionLog('Temporaere Deaktivierung wird ausgefuehrt.', 'info', `Ziel: ${targetLabel(instance)} | Dauer: ${payload.minutes} Minuten`);
await apiCall('disable', payload);
} else if (action === 'gravity') {
appendActionLog('Listen-Update wurde gestartet.', 'info', `Ziel: ${targetLabel(instance)}`);
await apiCall('gravity', payload);
const status = query('[data-list-update-status]');
if (status) status.textContent = 'Listen-Update gestartet.';
} else if (action === 'update') {
appendActionLog('Pi-hole-Update wurde gestartet.', 'info', `Ziel: ${targetLabel(instance)}`);
await apiCall('update', payload);
}
appendActionLog('Aktion erfolgreich abgeschlossen.', 'success', 'Dashboard wird jetzt aktualisiert.');
if (action === 'gravity') {
await monitorGravityProgress(baselineData, targetLabel(instance));
} else {
await loadDashboard();
}
} catch (err) {
appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error');
} finally {
setActionLock(false);
}
});
const domainForm = query('[data-domain-form]');
domainForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(domainForm);
const type = String(formData.get('type') || 'block');
const domain = String(formData.get('domain') || '').trim();
const status = query('[data-domain-status]');
if (!domain) return;
try {
await apiCall('domain_add', { type, domain, instance: 'primary' });
if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
domainForm.reset();
} catch (err) {
if (status) status.textContent = `Fehler: ${err.message}`;
}
});
const adlistForm = query('[data-adlist-form]');
adlistForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(adlistForm);
const url = String(formData.get('url') || '').trim();
const status = query('[data-adlist-status]');
if (!url) return;
try {
await apiCall('adlist_add', { url, instance: 'primary' });
if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
adlistForm.reset();
} catch (err) {
if (status) status.textContent = `Fehler: ${err.message}`;
}
});
const stopAutoRefresh = () => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer);
refreshTimer = null;
}
};
const startAutoRefresh = () => {
stopAutoRefresh();
if (refreshSeconds <= 0 || document.visibilityState !== 'visible') return;
refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000);
};
loadDashboard();
startAutoRefresh();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadDashboard();
startAutoRefresh();
} else {
stopAutoRefresh();
}
});
window.addEventListener('beforeunload', stopAutoRefresh);
};
if (document.querySelector('[data-pihole-page]')) {
window.initPiholeModule(document);
}
})();

View File

@@ -0,0 +1,121 @@
(function () {
'use strict';
window.initPiholeInstancesModule = function initPiholeInstancesModule(rootNode) {
const scope = rootNode instanceof Element ? rootNode : document;
const modal = scope.querySelector('[data-instance-modal]');
const form = scope.querySelector('[data-instance-form]');
scope.querySelectorAll('[data-instance-test]').forEach((button) => {
if (button.dataset.piholeBound === '1') return;
button.dataset.piholeBound = '1';
button.addEventListener('click', async (event) => {
event.preventDefault();
const card = button.closest('[data-instance-id]');
if (!card) return;
const instanceId = card.dataset.instanceId || '';
const resultEl = card.querySelector('[data-instance-result]');
if (resultEl) {
resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
resultEl.textContent = 'Teste Verbindung...';
}
try {
const res = await fetch(`/api/pihole/index.php?action=test`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ instance: instanceId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
if (resultEl) {
resultEl.classList.add(data.status ? `is-${data.status}` : 'is-ok');
resultEl.textContent = data.message || 'Verbindung OK.';
}
} catch (err) {
if (resultEl) {
resultEl.classList.add('is-error');
resultEl.textContent = `Test fehlgeschlagen: ${err.message || 'Fehler'}`;
}
}
});
});
if (!modal || !form || modal.dataset.piholeBound === '1') return;
modal.dataset.piholeBound = '1';
const title = scope.querySelector('[data-instance-modal-title]');
const closeBtn = scope.querySelector('[data-instance-close]');
const newBtn = scope.querySelector('[data-instance-new]');
const cancelBtn = scope.querySelector('[data-instance-cancel]');
const currentIdInput = form.querySelector('input[name="current_id"]');
const idInput = form.querySelector('input[name="instance_id"]');
const nameInput = form.querySelector('input[name="name"]');
const urlInput = form.querySelector('input[name="url"]');
const passwordInput = form.querySelector('input[name="password"]');
const primaryInput = form.querySelector('input[name="is_primary"]');
const resetForm = () => {
if (currentIdInput) currentIdInput.value = '';
if (idInput) idInput.value = '';
if (nameInput) nameInput.value = '';
if (urlInput) urlInput.value = '';
if (passwordInput) passwordInput.value = '';
if (primaryInput) primaryInput.checked = false;
};
const openModal = () => {
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
if (idInput) window.setTimeout(() => idInput.focus(), 20);
};
const closeModal = () => {
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
};
scope.querySelectorAll('[data-instance-edit]').forEach((btn) => {
btn.addEventListener('click', () => {
const card = btn.closest('[data-instance-id]');
if (!card) return;
if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
if (idInput) idInput.value = card.dataset.instanceId || '';
if (nameInput) nameInput.value = card.dataset.name || '';
if (urlInput) urlInput.value = card.dataset.url || '';
if (passwordInput) passwordInput.value = '';
if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
if (title) title.textContent = 'Instanz bearbeiten';
openModal();
});
});
newBtn?.addEventListener('click', () => {
resetForm();
if (title) title.textContent = 'Neue Instanz';
openModal();
});
closeBtn?.addEventListener('click', closeModal);
cancelBtn?.addEventListener('click', () => {
resetForm();
closeModal();
});
modal.addEventListener('click', (event) => {
if (event.target === modal) closeModal();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
closeModal();
}
});
};
if (document.querySelector('[data-instance-modal]')) {
window.initPiholeInstancesModule(document);
}
})();

View File

@@ -0,0 +1,116 @@
<?php
use App\ModuleConfigException;
$moduleName = 'pihole';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'settings', function () use ($moduleName): array {
return modules()->settings($moduleName);
});
$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array {
$settings = modules()->settings($moduleName);
$apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php'));
if ($apiPath === '') {
$apiPath = '/admin/api.php';
}
if ($apiPath[0] !== '/') {
$apiPath = '/' . $apiPath;
}
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
$normalizeSecret = static function (array $row): string {
$password = trim((string)($row['password'] ?? ''));
if ($password !== '') {
return $password;
}
return trim((string)($row['token'] ?? ''));
};
$instances = [];
$rawJson = trim((string)($settings['instances_json'] ?? ''));
if ($rawJson !== '') {
$decoded = json_decode($rawJson, true);
if (is_array($decoded)) {
foreach ($decoded as $row) {
if (!is_array($row)) {
continue;
}
$id = trim((string)($row['id'] ?? ''));
$url = trim((string)($row['url'] ?? ''));
if ($id === '' || $url === '') {
continue;
}
$instances[$id] = [
'id' => $id,
'name' => trim((string)($row['name'] ?? '')) ?: $id,
'url' => rtrim($url, '/'),
'password' => $normalizeSecret($row),
'api_path' => $apiPath,
'timeout' => $timeout,
'verify_tls' => $verifyTls,
'is_primary' => !empty($row['is_primary']),
];
}
}
}
if (empty($instances)) {
foreach (['primary', 'secondary'] as $key) {
$urlKey = $key . '_url';
$tokenKey = $key . '_token';
$nameKey = $key . '_name';
$url = trim((string)($settings[$urlKey] ?? ''));
if ($url === '') {
continue;
}
$instances[$key] = [
'id' => $key,
'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
'url' => rtrim($url, '/'),
'password' => trim((string)($settings[$tokenKey] ?? '')),
'api_path' => $apiPath,
'timeout' => $timeout,
'verify_tls' => $verifyTls,
'is_primary' => $key === 'primary',
];
}
}
$primaryId = trim((string)($settings['primary_id'] ?? ''));
if ($primaryId !== '' && isset($instances[$primaryId])) {
foreach ($instances as $id => &$row) {
$row['is_primary'] = ($id === $primaryId);
}
unset($row);
} else {
$hasPrimary = false;
foreach ($instances as $row) {
if (!empty($row['is_primary'])) {
$hasPrimary = true;
break;
}
}
if (!$hasPrimary && $instances) {
$firstKey = array_key_first($instances);
if ($firstKey !== null) {
$instances[$firstKey]['is_primary'] = true;
}
}
}
return $instances;
});
$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
$settings = modules()->settings($moduleName);
return !empty($settings['lists_primary_only']);
});

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
return [
'api_path' => '/admin/api.php',
'api_timeout_sec' => 8,
'action_timeout_sec' => 120,
'dashboard_refresh_sec' => 1,
'lists_refresh_sec' => 5,
'queries_refresh_sec' => 5,
'verify_tls' => true,
'lists_primary_only' => false,
'instances_json' => '',
'primary_id' => '',
];

View File

@@ -0,0 +1,16 @@
{
"eyebrow": "Modul",
"title": "Pi-hole",
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
"actions": [
{ "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "variant": "ghost" },
{ "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "variant": "secondary" }
],
"tabs": [
{ "label": "Dashboard", "href": "/apps/pihole/index.php?view=dashboard", "view": "dashboard" },
{ "label": "Listen", "href": "/apps/pihole/index.php?view=lists", "view": "lists" },
{ "label": "Queries", "href": "/apps/pihole/index.php?view=queries", "view": "queries" },
{ "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "view": "instances" },
{ "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "view": "setup" }
]
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
return [
'app_id' => 'pihole',
'title' => (string) ($manifest['title'] ?? 'Pi-hole'),
'icon' => 'PH',
'entry_route' => '/apps/pihole/index.php',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1220,
'default_height' => 860,
'supports_widget' => false,
'supports_tray' => false,
'module_name' => 'network',
'summary' => (string) ($manifest['description'] ?? 'Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.'),
'native_module' => [
'initializer' => 'initPiholeApp',
'options' => [
'entryRoute' => '/apps/pihole/index.php',
'defaultView' => 'dashboard',
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=pihole&path=assets/pihole.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.css') ? filemtime(__DIR__ . '/assets/pihole.css') : '0'))],
],
'scripts' => [
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.js') ? filemtime(__DIR__ . '/assets/pihole.js') : '0'))],
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole_instances.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole_instances.js') ? filemtime(__DIR__ . '/assets/pihole_instances.js') : '0'))],
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole-native.js') ? filemtime(__DIR__ . '/assets/pihole-native.js') : '0'))],
],
],
],
];

View File

@@ -0,0 +1,15 @@
# Pi-hole
Importiertes Nexus-Modul fuer Pi-hole Monitoring, Listenpflege und Instanzsteuerung.
## Desktop-Anbindung
- Desktop-App unter `/apps/pihole/index.php`
- API-Endpunkt unter `/api/pihole/index.php`
- native Modul-Einbettung mit globaler Desktop-Sidebar
## Settings
- bestehende Nexus-Settings werden ueber `nexus_module_settings` gelesen
- neue Overrides werden in `data/module-settings/pihole.json` gespeichert
- Instanzen werden weiterhin im Modul-Setting `instances_json` verwaltet

View File

@@ -0,0 +1,140 @@
{
"title": "Pi-hole",
"version": "0.1.0",
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
"enabled_by_default": true,
"app_scope": "module",
"installable": true,
"desktop": {
"available": true,
"show_on_desktop": true,
"show_in_start_menu": true
},
"widgets": [
{
"widget_id": "pihole-maintenance",
"title": "Pi-hole Wartung",
"icon": "PH",
"zone": "sidebar",
"default_enabled": false,
"supports_public_home": false,
"summary": "Gravity-Update und Listen-Refresh direkt aus dem Desktop.",
"content": "Schnellaktionen fuer die Pflege der Pi-hole-Instanzen.",
"launch_app_id": "pihole",
"action_label": "App oeffnen",
"widget_type": "action-panel",
"status_api": "/api/pihole/index.php?action=widget_status",
"actions": [
{
"action_id": "gravity",
"label": "Gravity aktualisieren",
"endpoint": "/api/pihole/index.php?action=gravity",
"method": "POST",
"payload": {
"instance": "primary"
}
},
{
"action_id": "update",
"label": "Pi-hole updaten",
"endpoint": "/api/pihole/index.php?action=update",
"method": "POST",
"payload": {
"instance": "primary"
},
"variant": "secondary"
}
]
}
],
"cron_jobs": [
{
"job_id": "gravity-primary",
"label": "Pi-hole Gravity Update",
"description": "Aktualisiert die Blocklisten auf der Primaer-Instanz.",
"endpoint_path": "/api/pihole/index.php?action=gravity",
"method": "POST",
"payload": {
"instance": "primary",
"trigger_source": "cron"
},
"default_enabled": false,
"default_cron": "20 4 * * *",
"lock_minutes": 30
},
{
"job_id": "update-primary",
"label": "Pi-hole Systemupdate",
"description": "Startet ein Update der Primaer-Instanz.",
"endpoint_path": "/api/pihole/index.php?action=update",
"method": "POST",
"payload": {
"instance": "primary",
"trigger_source": "cron"
},
"default_enabled": false,
"default_cron": "50 4 * * 0",
"lock_minutes": 45
}
],
"setup": {
"fields": [
{
"name": "api_path",
"label": "API Pfad",
"type": "text",
"help": "Standard ist /admin/api.php fuer v5-Instanzen. v6 wird automatisch erkannt.",
"required": false
},
{
"name": "verify_tls",
"label": "TLS Zertifikate pruefen",
"type": "checkbox",
"help": "Bei internen Testsystemen nur deaktivieren, wenn self-signed Zertifikate genutzt werden.",
"required": false
},
{
"name": "api_timeout_sec",
"label": "API Timeout Standard (Sekunden)",
"type": "number",
"help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.",
"required": false
},
{
"name": "action_timeout_sec",
"label": "API Timeout Aktionen (Sekunden)",
"type": "number",
"help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.",
"required": false
},
{
"name": "dashboard_refresh_sec",
"label": "Refresh Dashboard (Sekunden)",
"type": "number",
"help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.",
"required": false
},
{
"name": "lists_refresh_sec",
"label": "Refresh Listen (Sekunden)",
"type": "number",
"help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
"required": false
},
{
"name": "queries_refresh_sec",
"label": "Refresh Queries (Sekunden)",
"type": "number",
"help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
"required": false
},
{
"name": "lists_primary_only",
"label": "Listen nur auf Primaer-Instanz bearbeiten",
"type": "checkbox",
"help": "Erzwingt Listen- und Domain-Aenderungen nur auf der Primaer-Instanz.",
"required": false
}
]
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$isPartial = !empty($_GET['partial']);
$view = trim((string) ($_GET['view'] ?? 'dashboard'));
$allowedViews = ['dashboard', 'lists', 'queries', 'instances', 'setup'];
if (!in_array($view, $allowedViews, true)) {
$view = 'dashboard';
}
$viewMeta = [
'dashboard' => [
'label' => 'Dashboard',
'title' => 'Pi-hole Dashboard',
'description' => 'Status, Blockings, Usage und direkte Steuerung fuer alle angebundenen Instanzen.',
'nav_description' => 'Live-Status, Sperren und aggregierte DNS-Zahlen.',
],
'lists' => [
'label' => 'Listen',
'title' => 'Listen & Domains',
'description' => 'Gravity-Updates, Top-Domains und neue Listen-/Domain-Eintraege.',
'nav_description' => 'Blocklisten, Whitelist und Adlist-Pflege.',
],
'queries' => [
'label' => 'Queries',
'title' => 'Zugriffe & Blockings',
'description' => 'Aktuelle Blockings und Top-Clients ueber alle Instanzen.',
'nav_description' => 'Abfragen, blockierte Domains und Client-Nutzung.',
],
'instances' => [
'label' => 'Instanzen',
'title' => 'Pi-hole Instanzen',
'description' => 'Hosts, Zugangsdaten und Primaer-Instanz administrieren.',
'nav_description' => 'Instanzen anlegen, testen, bearbeiten und loeschen.',
],
'setup' => [
'label' => 'Setup',
'title' => 'Setup',
'description' => 'Timeouts, API-Pfad und globale Pi-hole-Moduloptionen verwalten.',
'nav_description' => 'Standard-Timeouts, TLS und Refresh-Regeln.',
],
];
$assetVersion = static function (string $relativePath): string {
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
$renderSetup = static function (): void {
$moduleName = 'pihole';
$manifestPath = dirname(__DIR__) . '/module.json';
$manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
$manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
$fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
$settings = modules()->settings($moduleName);
$notice = null;
$error = null;
$getValue = static function (array $source, string $path): mixed {
$value = $source;
foreach (explode('.', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
};
$setValue = static function (array &$target, string $path, mixed $value): void {
$segments = explode('.', $path);
$cursor = &$target;
foreach ($segments as $index => $segment) {
if ($index === count($segments) - 1) {
$cursor[$segment] = $value;
return;
}
if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
$cursor[$segment] = [];
}
$cursor = &$cursor[$segment];
}
};
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') {
try {
$nextSettings = $settings;
foreach ($fields as $field) {
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$type = trim((string) ($field['type'] ?? 'text'));
if ($name === '') {
continue;
}
$raw = $_POST['settings'][$name] ?? null;
$value = match ($type) {
'checkbox' => $raw !== null,
'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null),
default => is_string($raw) ? trim($raw) : '',
};
$setValue($nextSettings, $name, $value);
}
modules()->saveSettings($moduleName, $nextSettings);
$settings = modules()->settings($moduleName);
$notice = 'Setup gespeichert.';
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
?>
<div class="pihole-content">
<?php if ($error): ?>
<section class="window-app-card"><div class="pihole-message pihole-message--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="window-app-card"><div class="pihole-message pihole-message--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="window-app-card">
<div class="pihole-section-header">
<div>
<h3 class="pihole-section-title">Modul-Setup</h3>
<p class="window-app-copy">Bestehende Nexus-Einstellungen werden uebernommen. Aenderungen werden lokal als Override gespeichert.</p>
</div>
</div>
<form method="post" class="pihole-setup-form">
<input type="hidden" name="action" value="save_setup">
<div class="pihole-setup-grid">
<?php foreach ($fields as $field): ?>
<?php
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$label = trim((string) ($field['label'] ?? $name));
$type = trim((string) ($field['type'] ?? 'text'));
$help = trim((string) ($field['help'] ?? ''));
$value = $getValue($settings, $name);
?>
<label class="pihole-field">
<span><?= e($label) ?></span>
<?php if ($type === 'checkbox'): ?>
<input type="checkbox" name="settings[<?= e($name) ?>]" value="1" <?= !empty($value) ? 'checked' : '' ?>>
<?php elseif ($type === 'number'): ?>
<input type="number" step="any" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php else: ?>
<input type="<?= $type === 'password' ? 'password' : 'text' ?>" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php endif; ?>
<?php if ($help !== ''): ?>
<small class="window-app-meta"><?= e($help) ?></small>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="pihole-actions">
<button class="window-app-nav-button pihole-primary-action" type="submit">Setup speichern</button>
</div>
</form>
</section>
</div>
<?php
};
$renderContent = static function () use ($view, $renderSetup, $viewMeta): void {
$currentViewMeta = $viewMeta[$view] ?? $viewMeta['dashboard'];
$navItems = [
'dashboard' => '/apps/pihole/index.php?view=dashboard',
'lists' => '/apps/pihole/index.php?view=lists',
'queries' => '/apps/pihole/index.php?view=queries',
'instances' => '/apps/pihole/index.php?view=instances',
'setup' => '/apps/pihole/index.php?view=setup',
];
ob_start();
if ($view === 'setup') {
$renderSetup();
} else {
require dirname(__DIR__) . '/partials/' . $view . '.php';
}
$innerContent = (string) ob_get_clean();
?>
<div class="pihole-module-shell window-app-shell">
<div class="window-app-frame pihole-frame">
<aside class="window-app-sidebar pihole-sidebar">
<div class="window-app-brand pihole-brand">
<p class="window-app-kicker">Modul</p>
<h1>Pi-hole</h1>
<p class="window-app-copy">Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen im Desktop-Standardlayout.</p>
</div>
<div class="window-app-nav-list pihole-nav-list">
<?php foreach ($navItems as $navView => $href): ?>
<?php $meta = $viewMeta[$navView] ?? null; ?>
<?php if (!$meta): continue; endif; ?>
<a class="window-app-nav-button pihole-nav-button<?= $view === $navView ? ' is-active' : '' ?>" href="<?= e($href) ?>">
<strong><?= e((string) $meta['label']) ?></strong>
<span class="window-app-meta"><?= e((string) $meta['nav_description']) ?></span>
</a>
<?php endforeach; ?>
</div>
</aside>
<main class="window-app-main pihole-main">
<div class="window-app-panel pihole-panel-shell">
<section class="window-app-hero pihole-hero">
<div>
<p class="window-app-kicker">Pi-hole</p>
<h2 class="window-app-title"><?= e((string) $currentViewMeta['title']) ?></h2>
<p class="window-app-copy"><?= e((string) $currentViewMeta['description']) ?></p>
</div>
<div class="window-app-pill-row pihole-pill-row">
<span class="window-app-pill">Desktop-App</span>
<span class="window-app-pill">Netzwerk-Tools</span>
<span class="window-app-pill">View: <?= e((string) $currentViewMeta['label']) ?></span>
</div>
</section>
<?= $innerContent ?>
</div>
</main>
</div>
</div>
<?php
};
ob_start();
$renderContent();
$pageContent = (string) ob_get_clean();
if ($isPartial) {
echo $pageContent;
return;
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pi-hole</title>
<link rel="stylesheet" href="/assets/desktop/desktop.css">
<link rel="stylesheet" href="/module-assets/index.php?module=pihole&amp;path=assets/pihole.css&amp;v=<?= htmlspecialchars($assetVersion('assets/pihole.css'), ENT_QUOTES) ?>">
</head>
<body>
<?= $pageContent ?>
<script src="/module-assets/index.php?module=pihole&amp;path=assets/pihole.js&amp;v=<?= htmlspecialchars($assetVersion('assets/pihole.js'), ENT_QUOTES) ?>"></script>
<script src="/module-assets/index.php?module=pihole&amp;path=assets/pihole_instances.js&amp;v=<?= htmlspecialchars($assetVersion('assets/pihole_instances.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,134 @@
<?php
$settings = modules()->settings('pihole');
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1);
if ($refreshSeconds < 0) {
$refreshSeconds = 1;
}
?>
<div class="pihole-content pihole-page" data-pihole-page="dashboard" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Hosts</strong>
<a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a>
</div>
<?php if (!$instances): ?>
<div class="muted" style="margin-top:.75rem;">Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-primary-action" href="/apps/pihole/index.php?view=instances">+ Neue Instanz</a></div>
<?php else: ?>
<div class="pihole-list" style="margin-top:1rem;">
<?php foreach ($instances as $instance): ?>
<div class="pihole-list-row">
<div>
<strong><?= e((string)($instance['name'] ?? $instance['id'] ?? '')) ?></strong>
<div class="muted"><?= e((string)($instance['url'] ?? '')) ?></div>
</div>
<?php if (!empty($instance['is_primary'])): ?>
<span class="pihole-status">Primaer</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php if (!$hasConfig): ?>
<?php return; ?>
<?php else: ?>
<div class="pihole-grid">
<div class="window-app-card pihole-stat">
<div class="muted">DNS Queries (heute)</div>
<div class="pihole-stat-value" data-summary-dns></div>
</div>
<div class="window-app-card pihole-stat">
<div class="muted">Ads geblockt</div>
<div class="pihole-stat-value" data-summary-blocked></div>
<div class="pihole-stat-sub" data-summary-percent></div>
</div>
<div class="window-app-card pihole-stat">
<div class="muted">Unique Clients</div>
<div class="pihole-stat-value" data-summary-clients></div>
</div>
<div class="window-app-card pihole-stat">
<div class="muted">Status</div>
<div class="pihole-stat-value" data-summary-status></div>
</div>
</div>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Blocker steuern (alle Instanzen)</strong>
<span class="muted" data-summary-last-refresh>Letztes Update: </span>
</div>
<div class="pihole-actions" data-global-actions>
<button class="cta-button" data-action="enable" data-instance="all">Aktivieren</button>
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="all">5 Min</button>
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="all">10 Min</button>
<button class="nav-link" data-action="disable" data-minutes="20" data-instance="all">20 Min</button>
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="all">30 Min</button>
<button class="nav-link" data-action="disable" data-minutes="60" data-instance="all">60 Min</button>
<div class="pihole-inline">
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="all">
<button class="nav-link" data-action="disable-custom" data-instance="all">Custom</button>
</div>
</div>
</section>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Instanzen</strong>
<span class="muted">Einzeln steuerbar &amp; getrennte Updates</span>
</div>
<div class="pihole-instance-grid" data-instance-cards></div>
</section>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Usage (Aggregiert)</strong>
<span class="muted">Query-Typen und Weiterleitungen</span>
</div>
<div class="pihole-split" style="margin-top:1rem;">
<div>
<div class="muted">Query-Typen</div>
<div class="pihole-list" data-query-types></div>
</div>
<div>
<div class="muted">Forward Destinations</div>
<div class="pihole-list" data-forward-destinations></div>
</div>
</div>
</section>
<?php endif; ?>
</div>
<template id="pihole-instance-template">
<div class="window-app-card pihole-instance" data-instance="">
<div class="pihole-instance-header">
<div>
<strong data-instance-name></strong>
<div class="muted" data-instance-url></div>
</div>
<div class="pihole-status" data-instance-status></div>
</div>
<div class="pihole-instance-stats">
<div><span class="muted">DNS heute</span><div class="pihole-instance-value" data-instance-dns></div></div>
<div><span class="muted">Ads geblockt</span><div class="pihole-instance-value" data-instance-ads></div></div>
<div><span class="muted">% Blocked</span><div class="pihole-instance-value" data-instance-percent></div></div>
</div>
<div class="pihole-actions" data-instance-actions>
<button class="cta-button" data-action="enable" data-instance="">Aktivieren</button>
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="">5 Min</button>
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="">10 Min</button>
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="">30 Min</button>
<div class="pihole-inline">
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="">
<button class="nav-link" data-action="disable-custom" data-instance="">Custom</button>
</div>
<button class="nav-link" data-action="update" data-instance="">Pi-hole Update</button>
</div>
<div class="pihole-update" data-instance-update></div>
<div class="pihole-error" data-instance-errors></div>
</div>
</template>

View File

@@ -0,0 +1,408 @@
<?php
$moduleName = 'pihole';
require_admin();
$settings = modules()->settings($moduleName);
$notice = null;
$error = null;
$testResults = [];
$httpRequest = static function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array {
$raw = '';
$httpCode = 0;
$requestError = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => $verify,
CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = (string)curl_exec($ch);
if ($raw === '' && curl_errno($ch)) {
$requestError = curl_error($ch);
}
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
} else {
$ctx = stream_context_create([
'http' => [
'method' => $method,
'timeout' => $timeout,
'header' => implode("\r\n", $headers),
'content' => $body ?? '',
],
'ssl' => [
'verify_peer' => $verify,
'verify_peer_name' => $verify,
],
]);
$raw = (string)@file_get_contents($url, false, $ctx);
if ($raw === '') {
$requestError = 'HTTP request failed';
} else {
$httpCode = 200;
}
}
if ($requestError !== '') {
return ['ok' => false, 'http_code' => $httpCode, 'error' => $requestError, 'raw' => $raw, 'url' => $url];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return ['ok' => false, 'http_code' => $httpCode, 'error' => 'invalid_json', 'raw' => $raw, 'url' => $url];
}
return ['ok' => true, 'http_code' => $httpCode, 'data' => $decoded, 'url' => $url];
};
$runConnectionTest = static function (array $instance, array $settings) use ($httpRequest): array {
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$url = rtrim((string)($instance['url'] ?? ''), '/');
$password = trim((string)($instance['password'] ?? ''));
$v6AuthUrl = $url . '/api/auth';
$v6Payload = json_encode(['password' => $password], JSON_UNESCAPED_UNICODE);
$v6Auth = $httpRequest('POST', $v6AuthUrl, ['Accept: application/json', 'Content-Type: application/json'], $v6Payload, $verifyTls, $timeout);
if ($v6Auth['ok']) {
$session = (array)(($v6Auth['data']['session'] ?? []) ?: []);
$sid = trim((string)($session['sid'] ?? ''));
if ($sid !== '') {
$probe = $httpRequest('GET', $url . '/api/stats/summary', ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verifyTls, $timeout);
if ($probe['ok']) {
return [
'ok' => true,
'status' => 'ok',
'message' => 'Verbindung OK. API v6 antwortet.',
'version' => 6,
'details' => ['auth' => $v6Auth, 'probe' => $probe],
];
}
return [
'ok' => false,
'status' => 'error',
'message' => 'v6 Auth OK, aber Stats-Endpoint antwortet nicht sauber.',
'version' => 6,
'details' => ['auth' => $v6Auth, 'probe' => $probe],
];
}
}
$legacyUrl = $url . '/admin/api.php?summaryRaw';
if ($password !== '') {
$legacyUrl .= '&auth=' . rawurlencode($password);
}
$v5Probe = $httpRequest('GET', $legacyUrl, ['Accept: application/json'], null, $verifyTls, $timeout);
if ($v5Probe['ok']) {
return [
'ok' => true,
'status' => 'ok',
'message' => 'Verbindung OK. API v5 antwortet.',
'version' => 5,
'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
];
}
$status = 'error';
$message = 'Unbekannter Fehler.';
$httpCode = (int)($v6Auth['http_code'] ?? $v5Probe['http_code'] ?? 0);
$requestError = (string)($v6Auth['error'] ?? $v5Probe['error'] ?? 'error');
if ($httpCode === 0) {
$status = 'unreachable';
$message = 'Host nicht erreichbar oder kein HTTP-Response.';
} elseif ($httpCode === 401 || $httpCode === 403) {
$status = 'auth';
$message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.';
} elseif ($requestError === 'invalid_json') {
$status = 'invalid';
$message = 'API antwortet nicht mit JSON. URL pruefen.';
} else {
$message = 'API Fehler: ' . $requestError . ' (HTTP ' . $httpCode . ')';
}
return [
'ok' => false,
'status' => $status,
'message' => $message,
'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
];
};
$loadInstances = function (array $settings): array {
$normalizeSecret = static function (array $row): string {
$password = trim((string)($row['password'] ?? ''));
if ($password !== '') {
return $password;
}
return trim((string)($row['token'] ?? ''));
};
$instances = [];
$rawJson = trim((string)($settings['instances_json'] ?? ''));
if ($rawJson !== '') {
$decoded = json_decode($rawJson, true);
if (is_array($decoded)) {
foreach ($decoded as $row) {
if (!is_array($row)) {
continue;
}
$id = trim((string)($row['id'] ?? ''));
$url = trim((string)($row['url'] ?? ''));
if ($id === '' || $url === '') {
continue;
}
$instances[$id] = [
'id' => $id,
'name' => trim((string)($row['name'] ?? '')) ?: $id,
'url' => $url,
'password' => $normalizeSecret($row),
'is_primary' => !empty($row['is_primary']),
];
}
}
}
if (!$instances) {
foreach (['primary', 'secondary'] as $key) {
$url = trim((string)($settings[$key . '_url'] ?? ''));
if ($url === '') {
continue;
}
$instances[$key] = [
'id' => $key,
'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
'url' => $url,
'password' => trim((string)($settings[$key . '_token'] ?? '')),
'is_primary' => $key === 'primary',
];
}
}
return $instances;
};
$instances = $loadInstances($settings);
$sanitizeId = function (string $id): string {
$id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id);
return trim((string)$id);
};
$saveInstances = function (array $settings, array $instances): void {
$payload = $settings;
$payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE);
modules()->saveSettings('pihole', $payload);
};
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$deleteId = trim((string)($_POST['delete_id'] ?? ''));
$testId = trim((string)($_POST['test_id'] ?? ''));
$currentId = trim((string)($_POST['current_id'] ?? ''));
$instanceId = trim((string)($_POST['instance_id'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
$url = trim((string)($_POST['url'] ?? ''));
$password = trim((string)($_POST['password'] ?? ''));
$isPrimary = isset($_POST['is_primary']);
if ($testId !== '') {
if (isset($instances[$testId])) {
module_debug_push('pihole', [
'label' => 'connection.test.start',
'instance_id' => $testId,
'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
'url' => (string)($instances[$testId]['url'] ?? ''),
]);
$result = $runConnectionTest($instances[$testId], $settings);
$testResults[$testId] = $result;
module_debug_push('pihole', [
'label' => 'connection.test.result',
'instance_id' => $testId,
'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
'result' => $result,
]);
$notice = $result['message'] ?? null;
} else {
$error = 'Test-Instanz nicht gefunden.';
}
} elseif ($deleteId !== '') {
if (isset($instances[$deleteId])) {
unset($instances[$deleteId]);
$notice = 'Instanz geloescht.';
}
} else {
$instanceId = $sanitizeId($instanceId);
if ($instanceId === '' || $url === '') {
$error = 'Bitte ID und URL angeben.';
} elseif ($currentId === '' && isset($instances[$instanceId])) {
$error = 'Die Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
} elseif ($currentId !== '' && $currentId !== $instanceId && isset($instances[$instanceId])) {
$error = 'Die neue Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
} else {
$existingPassword = '';
if ($currentId !== '' && isset($instances[$currentId])) {
$existingPassword = (string)($instances[$currentId]['password'] ?? '');
}
$passwordToStore = $password !== '' ? $password : $existingPassword;
if ($currentId !== '' && $currentId !== $instanceId) {
unset($instances[$currentId]);
}
$instances[$instanceId] = [
'id' => $instanceId,
'name' => $name !== '' ? $name : $instanceId,
'url' => $url,
'password' => $passwordToStore,
'is_primary' => $isPrimary,
];
if ($isPrimary) {
foreach ($instances as $id => &$row) {
$row['is_primary'] = ($id === $instanceId);
}
unset($row);
$settings['primary_id'] = $instanceId;
}
$notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.';
}
}
if (!$error) {
$saveInstances($settings, $instances);
$settings = modules()->settings($moduleName);
$instances = $loadInstances($settings);
}
}
$primaryId = trim((string)($settings['primary_id'] ?? ''));
if ($primaryId === '') {
foreach ($instances as $id => $row) {
if (!empty($row['is_primary'])) {
$primaryId = $id;
break;
}
}
}
?>
<div class="pihole-content">
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<div>
<h3 class="pihole-section-title">Instanzen</h3>
<p class="window-app-copy">Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.</p>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="window-app-nav-button pihole-primary-action" type="button" data-instance-new>+ Neue Instanz</button>
</div>
</div>
<?php if ($error): ?>
<div class="pihole-message pihole-message--error" style="margin-top:16px;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="pihole-message pihole-message--success" style="margin-top:16px;">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="pihole-instance-grid" style="margin-top:16px;">
<?php if (!$instances): ?>
<div class="window-app-card pihole-card">Keine Instanzen vorhanden.</div>
<?php else: ?>
<?php foreach ($instances as $instance): ?>
<div class="window-app-card pihole-instance-card"
data-instance-id="<?= e((string)$instance['id']) ?>"
data-name="<?= e((string)($instance['name'] ?? '')) ?>"
data-url="<?= e((string)($instance['url'] ?? '')) ?>"
data-primary="<?= !empty($instance['is_primary']) ? '1' : '0' ?>">
<div class="pihole-instance-header">
<div>
<strong><?= e((string)($instance['name'] ?? '')) ?></strong>
<div class="muted">ID: <?= e((string)($instance['id'] ?? '')) ?></div>
<div class="muted">URL: <?= e((string)($instance['url'] ?? '')) ?></div>
</div>
<?php if (!empty($instance['is_primary']) || $instance['id'] === $primaryId): ?>
<span class="pihole-status">Primaer</span>
<?php endif; ?>
</div>
<div class="pihole-card-actions">
<button class="window-app-nav-button pihole-link-button" type="button" data-instance-edit>Bearbeiten</button>
<form method="post">
<input type="hidden" name="test_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
<button class="window-app-nav-button pihole-link-button" type="submit" data-instance-test>Test Verbindung</button>
</form>
<form method="post" onsubmit="return confirm('Instanz wirklich loeschen?')">
<input type="hidden" name="delete_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
<button class="window-app-nav-button pihole-link-button" type="submit">Loeschen</button>
</form>
</div>
<div class="pihole-test-result<?= !empty($testResults[$instance['id']]['status']) ? ' is-' . e((string)$testResults[$instance['id']]['status']) : '' ?>" data-instance-result><?= e((string)($testResults[$instance['id']]['message'] ?? '')) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
</div>
<div class="modal" data-instance-modal aria-hidden="true">
<div class="modal-card pihole-modal-card" role="dialog" aria-modal="true" aria-labelledby="pihole-instance-modal-title">
<div class="modal-header">
<div>
<strong id="pihole-instance-modal-title" data-instance-modal-title>Neue Instanz</strong>
<div class="muted">Pi-hole Host anlegen oder bestehende Instanz bearbeiten.</div>
</div>
<button class="icon-button" type="button" data-instance-close aria-label="Modal schliessen">×</button>
</div>
<form method="post" class="pihole-instance-form" data-instance-form>
<input type="hidden" name="current_id" value="">
<div class="form-grid pihole-instance-form-grid">
<label class="form-field pihole-form-field-wide">
<span class="muted">ID</span>
<input type="text" name="instance_id" placeholder="z.B. pihole-main" required>
<small class="muted">Die ID muss eindeutig sein und wird nur intern verwendet.</small>
</label>
<label class="form-field">
<span class="muted">Name</span>
<input type="text" name="name" placeholder="z.B. Pi-hole Main" required>
</label>
<label class="form-field">
<span class="muted">URL</span>
<input type="text" name="url" placeholder="http://pi-hole.local" required>
</label>
<label class="form-field pihole-form-field-wide">
<span class="muted">Passwort / App-Passwort</span>
<input type="password" name="password" placeholder="Pi-hole Passwort oder App-Passwort" autocomplete="new-password">
<small class="muted">Beim Bearbeiten leer lassen, um das gespeicherte Passwort unveraendert zu lassen.</small>
</label>
<label class="pihole-checkbox-field">
<input type="checkbox" name="is_primary" value="1">
<span>Als Primaer verwenden</span>
</label>
</div>
<div class="pihole-modal-actions">
<button class="window-app-nav-button pihole-primary-action" type="submit" data-instance-submit>Speichern</button>
<button class="window-app-nav-button pihole-link-button" type="button" data-instance-cancel>Abbrechen</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<?php
$settings = modules()->settings('pihole');
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5);
if ($refreshSeconds < 0) {
$refreshSeconds = 5;
}
?>
<div class="pihole-content pihole-page" data-pihole-page="lists" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
<?php if (!$hasConfig): ?>
<div class="window-app-card pihole-card">
<strong>Keine Instanzen konfiguriert</strong>
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a></div>
</div>
<?php else: ?>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Listen-Updates</strong>
<span class="muted">Gravity / Blocklisten fuer alle oder einzelne Instanzen neu laden</span>
</div>
<div class="pihole-actions" data-list-actions>
<button class="cta-button" data-action="gravity" data-instance="all">Alle Instanzen aktualisieren</button>
<?php foreach ($instances as $instance): ?>
<button class="nav-link" data-action="gravity" data-instance="<?= e((string)($instance['id'] ?? '')) ?>">
<?= e((string)($instance['name'] ?? $instance['id'] ?? 'Instanz')) ?>
</button>
<?php endforeach; ?>
</div>
<div class="pihole-update" data-list-update-status></div>
</section>
<div class="pihole-split">
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Top geblockte Domains (Aggregiert)</strong>
<span class="muted">Letzte Statistiken</span>
</div>
<div class="pihole-list" data-top-ads></div>
</div>
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Top erlaubte Domains (Aggregiert)</strong>
<span class="muted">Letzte Statistiken</span>
</div>
<div class="pihole-list" data-top-queries></div>
</div>
</div>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Domainlisten erweitern</strong>
<span class="muted">Eintraege werden auf der Primaer-Instanz gesetzt</span>
</div>
<form class="pihole-form" data-domain-form>
<label>
<span class="muted">Typ</span>
<select name="type">
<option value="block">Blacklist (Blocken)</option>
<option value="allow">Whitelist (Erlauben)</option>
</select>
</label>
<label>
<span class="muted">Domain</span>
<input type="text" name="domain" placeholder="example.com" required>
</label>
<button class="cta-button" type="submit">Hinzufuegen</button>
</form>
<div class="pihole-update" data-domain-status></div>
</section>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Adlist-URL hinzufuegen</strong>
<span class="muted">Optional: unterstuetzt nur wenn die API den Endpunkt anbietet.</span>
</div>
<form class="pihole-form" data-adlist-form>
<label>
<span class="muted">Adlist URL</span>
<input type="text" name="url" placeholder="https://example.com/list.txt" required>
</label>
<button class="nav-link" type="submit">Adlist hinzufuegen</button>
</form>
<div class="pihole-update" data-adlist-status></div>
</section>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,35 @@
<?php
$settings = modules()->settings('pihole');
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5);
if ($refreshSeconds < 0) {
$refreshSeconds = 5;
}
?>
<div class="pihole-content pihole-page" data-pihole-page="queries" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
<?php if (!$hasConfig): ?>
<div class="window-app-card pihole-card">
<strong>Keine Instanzen konfiguriert</strong>
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a></div>
</div>
<?php else: ?>
<div class="pihole-split">
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Aktuelle Blockings</strong>
<span class="muted">Letzte geblockte Domains</span>
</div>
<div class="pihole-blocked" data-recent-blocked></div>
</div>
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Top Clients (Aggregiert)</strong>
<span class="muted">Anfragen nach Client</span>
</div>
<div class="pihole-list" data-top-clients></div>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,287 @@
.salesforce-translation-import,
.salesforce-translation-import-page {
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(96, 165, 250, 0.10), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: #0f172a;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
.salesforce-translation-import-page {
min-height: 100vh;
}
.salesforce-translation-import {
height: 100%;
min-height: 0;
padding: 24px;
}
.salesforce-translation-import *,
.salesforce-translation-import *::before,
.salesforce-translation-import *::after {
box-sizing: border-box;
}
.salesforce-translation-import__shell {
display: flex;
flex-direction: column;
gap: 20px;
min-height: min-content;
}
.salesforce-translation-import__hero,
.salesforce-translation-import__card {
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.10);
}
.salesforce-translation-import__hero {
padding: 24px 28px;
}
.salesforce-translation-import__eyebrow {
margin: 0 0 12px;
color: #4338ca;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.salesforce-translation-import__title {
margin: 0;
font-size: clamp(2rem, 4vw, 2.8rem);
line-height: 1.05;
}
.salesforce-translation-import__lede {
max-width: 70rem;
margin: 14px 0 0;
color: #475569;
font-size: 1rem;
line-height: 1.6;
}
.salesforce-translation-import__grid {
display: grid;
gap: 20px;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
}
.salesforce-translation-import__main,
.salesforce-translation-import__side {
display: flex;
flex-direction: column;
gap: 20px;
min-width: 0;
}
.salesforce-translation-import__card {
padding: 22px 24px;
}
.salesforce-translation-import__card h2 {
margin: 0 0 14px;
font-size: 1.55rem;
line-height: 1.15;
}
.salesforce-translation-import__card p {
margin: 0 0 12px;
color: #475569;
line-height: 1.55;
}
.salesforce-translation-import__card ol {
margin: 0;
padding-left: 20px;
color: #334155;
line-height: 1.6;
}
.salesforce-translation-import__card li + li {
margin-top: 8px;
}
.salesforce-translation-import__sample {
overflow-x: auto;
margin-top: 14px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 18px;
}
.salesforce-translation-import__field {
display: flex;
flex-direction: column;
gap: 8px;
}
.salesforce-translation-import__field + .salesforce-translation-import__field {
margin-top: 18px;
}
.salesforce-translation-import__label {
font-weight: 700;
color: #1e293b;
}
.salesforce-translation-import__input,
.salesforce-translation-import__select {
width: 100%;
min-height: 48px;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 16px;
background: #ffffff;
color: #0f172a;
font: inherit;
}
.salesforce-translation-import__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 20px;
}
.salesforce-translation-import__button {
min-height: 48px;
padding: 11px 18px;
border: 1px solid rgba(99, 102, 241, 0.22);
border-radius: 999px;
background: #ffffff;
color: #111827;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.salesforce-translation-import__button:hover {
border-color: rgba(79, 70, 229, 0.4);
}
.salesforce-translation-import__button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.salesforce-translation-import__button--primary {
border-color: transparent;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff;
}
.salesforce-translation-import__status {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.salesforce-translation-import__status.is-muted {
color: #64748b;
}
.salesforce-translation-import__status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
}
.salesforce-translation-import__status-row:last-child {
border-bottom: 0;
}
.salesforce-translation-import__file-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.salesforce-translation-import__language-code {
padding: 2px 8px;
border: 1px solid rgba(148, 163, 184, 0.65);
border-radius: 999px;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.86rem;
}
.salesforce-translation-import__match-ok {
color: #166534;
font-weight: 700;
}
.salesforce-translation-import__match-missing {
color: #b91c1c;
font-weight: 700;
}
.salesforce-translation-import__muted {
color: #64748b;
}
.salesforce-translation-import__stats {
color: #334155;
line-height: 1.65;
}
.salesforce-translation-import__preview {
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 18px;
background: #ffffff;
}
.salesforce-translation-import table {
width: 100%;
border-collapse: collapse;
}
.salesforce-translation-import th,
.salesforce-translation-import td {
padding: 12px 14px;
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
vertical-align: top;
text-align: left;
}
.salesforce-translation-import thead th {
position: sticky;
top: 0;
background: #f8fafc;
color: #334155;
z-index: 1;
}
.salesforce-translation-import tbody tr:nth-child(even) {
background: rgba(248, 250, 252, 0.75);
}
.salesforce-translation-import__preview-empty {
padding: 20px 14px;
color: #64748b;
}
@media (max-width: 1100px) {
.salesforce-translation-import__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.salesforce-translation-import {
padding: 16px;
}
.salesforce-translation-import__hero,
.salesforce-translation-import__card {
padding: 18px;
border-radius: 22px;
}
}

View File

@@ -0,0 +1,916 @@
(function () {
'use strict';
const template = `
<div class="salesforce-translation-import window-app-shell">
<div class="salesforce-translation-import__shell">
<section class="salesforce-translation-import__hero">
<p class="salesforce-translation-import__eyebrow">Integration</p>
<h1 class="salesforce-translation-import__title">Salesforce Translation Import</h1>
<p class="salesforce-translation-import__lede">Clientseitiges Werkzeug fuer Salesforce-XLIFF-Dateien und Zieluebersetzungen aus Excel oder CSV. Der Export erzeugt pro Sprache eine separate XLIFF-Datei im ZIP.</p>
</section>
<div class="salesforce-translation-import__grid">
<div class="salesforce-translation-import__main">
<section class="salesforce-translation-import__card">
<h2>Import</h2>
<div class="salesforce-translation-import__field">
<label class="salesforce-translation-import__label" for="salesforce-translation-import-xml-files">Salesforce XLIFF export files</label>
<input class="salesforce-translation-import__input" type="file" id="salesforce-translation-import-xml-files" multiple accept=".zip,.xml,.xlf,.xliff">
<div id="salesforce-translation-import-source-status" class="salesforce-translation-import__status salesforce-translation-import__status is-muted">No Salesforce XLIFF export files selected.</div>
</div>
<div class="salesforce-translation-import__field">
<label class="salesforce-translation-import__label" for="salesforce-translation-import-target-file">Target translation (Excel or CSV)</label>
<input class="salesforce-translation-import__input" type="file" id="salesforce-translation-import-target-file" accept=".xlsx,.xls,.csv">
<div id="salesforce-translation-import-target-status" class="salesforce-translation-import__status salesforce-translation-import__status is-muted">No target translation file selected.</div>
<div id="salesforce-translation-import-generic-source-selection" class="salesforce-translation-import__field" hidden>
<label class="salesforce-translation-import__label" for="salesforce-translation-import-generic-source-select">Apply target translation to</label>
<select class="salesforce-translation-import__select" id="salesforce-translation-import-generic-source-select">
<option value="">Select a target XLIFF file</option>
</select>
</div>
</div>
<div class="salesforce-translation-import__field">
<label class="salesforce-translation-import__label" for="salesforce-translation-import-object-select">Preview object filter</label>
<select class="salesforce-translation-import__select" id="salesforce-translation-import-object-select">
<option value="">All objects</option>
</select>
</div>
<div class="salesforce-translation-import__actions">
<button class="salesforce-translation-import__button salesforce-translation-import__button--primary" type="button" id="salesforce-translation-import-analyze-btn">Create preview</button>
<button class="salesforce-translation-import__button" type="button" id="salesforce-translation-import-export-btn">Create ZIP</button>
<button class="salesforce-translation-import__button" type="button" id="salesforce-translation-import-extract-btn">Extract CSV</button>
</div>
</section>
<section class="salesforce-translation-import__card">
<h2>Preview</h2>
<div id="salesforce-translation-import-stats" class="salesforce-translation-import__stats"></div>
<div class="salesforce-translation-import__preview">
<table>
<thead>
<tr>
<th>File</th>
<th>Language</th>
<th>Object</th>
<th>Field</th>
<th>Current target</th>
<th>Result target</th>
</tr>
</thead>
<tbody id="salesforce-translation-import-preview-body">
<tr>
<td colspan="6" class="salesforce-translation-import__preview-empty">Noch keine Vorschau erstellt.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<aside class="salesforce-translation-import__side">
<section class="salesforce-translation-import__card">
<h2>Vorgehen</h2>
<ol>
<li>Salesforce-Zielsprachen als XLIFF exportieren. ZIP-Dateien koennen direkt hochgeladen werden.</li>
<li>Eine Excel- oder CSV-Datei mit den Spalten <code>object</code>, <code>field</code> und <code>target</code> vorbereiten.</li>
<li>Optional kann die Spalte <code>language</code> mehrere Zielsprachen in einer Datei abbilden.</li>
<li>Vorschau erzeugen, Matches pruefen und danach das Ergebnis als ZIP herunterladen.</li>
<li>Die exportierten XLIFF-Dateien einzeln wieder in Salesforce importieren.</li>
</ol>
</section>
<section class="salesforce-translation-import__card">
<h2>Beispielstruktur</h2>
<p>Header werden gross-/kleinschreibungsunabhaengig ausgewertet. Die Aliasnamen <code>Objekt</code> und <code>Feld</code> bleiben ebenfalls gueltig.</p>
<div class="salesforce-translation-import__sample">
<table>
<thead>
<tr>
<th>object</th>
<th>field</th>
<th>target</th>
<th>language</th>
</tr>
</thead>
<tbody>
<tr>
<td>Account</td>
<td>Name</td>
<td>Kontoname</td>
<td>de</td>
</tr>
<tr>
<td>Account</td>
<td>Name</td>
<td>Nom du compte</td>
<td>fr</td>
</tr>
</tbody>
</table>
</div>
</section>
</aside>
</div>
</div>
</div>
`;
function createEmptyTargetStats() {
return {
totalRows: 0,
withoutTarget: 0,
sameFieldAndTarget: 0,
incomplete: 0,
duplicates: 0,
usableUniqueRows: 0
};
}
function normalizeHeader(value) {
return String(value || '').trim().toLowerCase();
}
function normalizeLanguage(value) {
return String(value || '').trim().replaceAll('_', '-').toLowerCase();
}
function parseXml(xmlText) {
const xml = new DOMParser().parseFromString(xmlText, 'text/xml');
const parserError = xml.getElementsByTagName('parsererror')[0];
if (parserError) {
throw new Error(parserError.textContent || 'Invalid XML file');
}
return xml;
}
function getTargetLanguage(xml) {
const fileNode = xml.getElementsByTagName('file')[0];
const rawLanguage = fileNode ? fileNode.getAttribute('target-language') || '' : '';
return {
raw: rawLanguage.trim(),
normalized: normalizeLanguage(rawLanguage)
};
}
function findHeaderIndex(headers, aliases) {
return headers.findIndex((header) => aliases.includes(header));
}
function getUnitDetails(unit) {
const id = unit.getAttribute('id');
if (!id) {
return null;
}
const parts = id.split('.');
if (parts.length < 3) {
return null;
}
return {
objectName: parts[1].trim(),
fieldName: parts.slice(2).join('.').trim()
};
}
function removeUnit(unit) {
const whitespaceBefore = unit.previousSibling;
if (whitespaceBefore && whitespaceBefore.nodeType === 3 && /^\s*$/.test(whitespaceBefore.nodeValue)) {
whitespaceBefore.remove();
}
unit.remove();
}
function csvEscape(value) {
const text = String(value ?? '');
return '"' + text.replaceAll('"', '""') + '"';
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function buildApp(host, options) {
host.innerHTML = template;
const state = {
translationsByLanguage: new Map(),
sourceFiles: [],
sourceLoadPromise: Promise.resolve(),
previewChanges: [],
targetFileName: '',
targetRowCount: 0,
targetHasLanguageColumn: false,
targetStats: createEmptyTargetStats(),
selectedGenericSourceId: '',
sourceSequence: 0
};
const xmlInput = host.querySelector('#salesforce-translation-import-xml-files');
const targetInput = host.querySelector('#salesforce-translation-import-target-file');
const genericSourceSelect = host.querySelector('#salesforce-translation-import-generic-source-select');
const analyzeBtn = host.querySelector('#salesforce-translation-import-analyze-btn');
const exportBtn = host.querySelector('#salesforce-translation-import-export-btn');
const extractBtn = host.querySelector('#salesforce-translation-import-extract-btn');
const sourceStatus = host.querySelector('#salesforce-translation-import-source-status');
const targetStatus = host.querySelector('#salesforce-translation-import-target-status');
const statsNode = host.querySelector('#salesforce-translation-import-stats');
const previewBody = host.querySelector('#salesforce-translation-import-preview-body');
const objectSelect = host.querySelector('#salesforce-translation-import-object-select');
const genericSourceSelection = host.querySelector('#salesforce-translation-import-generic-source-selection');
function appendMetric(container, value, label, className) {
const metric = document.createElement('div');
metric.className = className || 'salesforce-translation-import__muted';
metric.textContent = value + ' ' + label;
container.appendChild(metric);
}
function resetObjectSelect() {
objectSelect.innerHTML = '<option value="">All objects</option>';
}
function populateObjectSelect(objects) {
resetObjectSelect();
[...objects].sort().forEach((objectName) => {
const option = document.createElement('option');
option.value = objectName;
option.textContent = objectName;
objectSelect.appendChild(option);
});
}
function getTranslationsForSource(source) {
if (source.error) {
return null;
}
if (source.language && state.translationsByLanguage.has(source.language)) {
return {
translations: state.translationsByLanguage.get(source.language),
label: source.language,
languageKey: source.language
};
}
if (
!state.targetHasLanguageColumn &&
(state.sourceFiles.filter((entry) => !entry.error).length === 1 || source.id === state.selectedGenericSourceId) &&
state.translationsByLanguage.has('')
) {
return {
translations: state.translationsByLanguage.get(''),
label: 'single-source mode',
languageKey: ''
};
}
return null;
}
function appendStatusRow(container, source) {
const row = document.createElement('div');
row.className = 'salesforce-translation-import__status-row';
const fileName = document.createElement('span');
fileName.className = 'salesforce-translation-import__file-name';
fileName.textContent = source.displayName || source.name;
row.appendChild(fileName);
if (source.error) {
const error = document.createElement('span');
error.className = 'salesforce-translation-import__match-missing';
error.textContent = 'Invalid XLIFF: ' + source.error;
row.appendChild(error);
container.appendChild(row);
return;
}
const language = document.createElement('span');
language.className = 'salesforce-translation-import__language-code';
language.textContent = source.languageLabel;
row.appendChild(language);
const unitCount = document.createElement('span');
unitCount.className = 'salesforce-translation-import__muted';
unitCount.textContent = source.unitCount + ' translation units';
row.appendChild(unitCount);
const match = getTranslationsForSource(source);
const matchText = document.createElement('span');
if (match) {
matchText.className = 'salesforce-translation-import__match-ok';
matchText.textContent = 'Matched: ' + match.label;
} else if (state.targetFileName && !state.targetHasLanguageColumn && state.sourceFiles.filter((entry) => !entry.error).length > 1) {
matchText.className = 'salesforce-translation-import__muted';
matchText.textContent = 'Not selected for this target translation';
} else if (state.targetFileName) {
matchText.className = 'salesforce-translation-import__match-missing';
matchText.textContent = 'No matching target translations';
} else {
matchText.className = 'salesforce-translation-import__muted';
matchText.textContent = 'Waiting for target translation';
}
row.appendChild(matchText);
container.appendChild(row);
}
function renderGenericSourceSelection() {
const validSources = state.sourceFiles.filter((entry) => !entry.error);
const selectionRequired = Boolean(state.targetFileName) && !state.targetHasLanguageColumn && validSources.length > 1;
genericSourceSelection.hidden = !selectionRequired;
if (!selectionRequired) {
state.selectedGenericSourceId = validSources.length === 1 ? validSources[0].id : '';
return;
}
const previousSelection = state.selectedGenericSourceId;
genericSourceSelect.innerHTML = '<option value="">Select a target XLIFF file</option>';
validSources.forEach((source) => {
const option = document.createElement('option');
option.value = source.id;
option.textContent = (source.displayName || source.name) + ' [' + source.languageLabel + ']';
genericSourceSelect.appendChild(option);
});
if (validSources.some((source) => source.id === previousSelection)) {
genericSourceSelect.value = previousSelection;
} else {
state.selectedGenericSourceId = '';
genericSourceSelect.value = '';
}
}
function renderFileStatus() {
renderGenericSourceSelection();
sourceStatus.innerHTML = '';
if (state.sourceFiles.length === 0) {
sourceStatus.textContent = 'No Salesforce XLIFF export files selected.';
sourceStatus.className = 'salesforce-translation-import__status salesforce-translation-import__status is-muted';
} else {
sourceStatus.className = 'salesforce-translation-import__status';
state.sourceFiles.forEach((source) => appendStatusRow(sourceStatus, source));
}
targetStatus.innerHTML = '';
if (!state.targetFileName) {
targetStatus.textContent = 'No target translation file selected.';
targetStatus.className = 'salesforce-translation-import__status salesforce-translation-import__status is-muted';
return;
}
targetStatus.className = 'salesforce-translation-import__status';
const summary = document.createElement('div');
summary.className = 'salesforce-translation-import__status-row';
const fileName = document.createElement('span');
fileName.className = 'salesforce-translation-import__file-name';
fileName.textContent = state.targetFileName;
summary.appendChild(fileName);
const details = document.createElement('span');
const languages = [...state.translationsByLanguage.keys()].filter(Boolean).sort();
details.textContent = state.targetHasLanguageColumn
? 'Languages: ' + (languages.join(', ') || 'none')
: 'No language column (single-source mode)';
summary.appendChild(details);
targetStatus.appendChild(summary);
appendMetric(targetStatus, state.targetStats.totalRows, 'rows in target translation file');
appendMetric(targetStatus, state.targetStats.withoutTarget, 'rows without a target value');
appendMetric(targetStatus, state.targetStats.sameFieldAndTarget, 'rows where field and target are identical');
appendMetric(targetStatus, state.targetStats.incomplete, 'incomplete rows');
appendMetric(targetStatus, state.targetStats.duplicates, 'duplicate object/field rows');
appendMetric(targetStatus, state.targetStats.usableUniqueRows, 'usable unique target rows', 'salesforce-translation-import__match-ok');
}
function renderPreview() {
previewBody.innerHTML = '';
if (state.previewChanges.length === 0) {
const emptyRow = document.createElement('tr');
emptyRow.innerHTML = '<td colspan="6" class="salesforce-translation-import__preview-empty">Noch keine Vorschau erstellt.</td>';
previewBody.appendChild(emptyRow);
return;
}
state.previewChanges.forEach((change) => {
const tr = document.createElement('tr');
[
change.file,
change.language,
change.object,
change.field,
change.oldValue,
change.newValue
].forEach((value) => {
const td = document.createElement('td');
td.textContent = value;
tr.appendChild(td);
});
previewBody.appendChild(tr);
});
}
function collectExtractionRows() {
const rows = [];
state.sourceFiles
.filter((source) => !source.error)
.forEach((source) => {
const xml = parseXml(source.text);
const units = Array.from(xml.getElementsByTagName('trans-unit'));
units.forEach((unit) => {
const details = getUnitDetails(unit);
if (!details) {
return;
}
const sourceNode = unit.getElementsByTagName('source')[0];
const targetNode = unit.getElementsByTagName('target')[0];
rows.push({
object: details.objectName,
field: details.fieldName,
target: targetNode ? targetNode.textContent ?? '' : '',
language: source.languageLabel,
source: sourceNode ? sourceNode.textContent ?? '' : '',
file: source.name
});
});
});
return rows;
}
function downloadCsv(filename, rows) {
const header = ['object', 'field', 'target', 'language', 'source', 'file'];
const lines = [
header.map(csvEscape).join(','),
...rows.map((row) => [
row.object,
row.field,
row.target,
row.language,
row.source,
row.file
].map(csvEscape).join(','))
];
const blob = new Blob(["\uFEFF" + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const link = document.createElement('a');
const downloadUrl = URL.createObjectURL(blob);
link.href = downloadUrl;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
}
function genericSourceSelectionIsMissing() {
return Boolean(state.targetFileName) &&
!state.targetHasLanguageColumn &&
state.sourceFiles.filter((entry) => !entry.error).length > 1 &&
!state.selectedGenericSourceId;
}
async function addSourceFile(name, text, displayName) {
try {
const xml = parseXml(text);
const language = getTargetLanguage(xml);
state.sourceFiles.push({
id: 'source-' + (++state.sourceSequence),
name: name,
displayName: displayName || name,
text: text,
language: language.normalized,
languageLabel: language.raw || 'Not specified',
unitCount: xml.getElementsByTagName('trans-unit').length
});
} catch (error) {
state.sourceFiles.push({
name: name,
displayName: displayName || name,
error: error.message
});
}
}
async function loadSourceZip(file) {
try {
const archive = await window.JSZip.loadAsync(await file.arrayBuffer());
const entries = Object.values(archive.files).filter((entry) => !entry.dir && /\.(xml|xlf|xliff)$/i.test(entry.name));
if (entries.length === 0) {
state.sourceFiles.push({
name: file.name,
error: 'The ZIP does not contain any XML or XLIFF files.'
});
return;
}
for (const entry of entries) {
await addSourceFile(entry.name, await entry.async('string'), file.name + ' / ' + entry.name);
}
} catch (error) {
state.sourceFiles.push({
name: file.name,
error: 'The ZIP could not be read: ' + error.message
});
}
}
async function loadSourceFiles() {
state.sourceFiles = [];
state.selectedGenericSourceId = '';
state.sourceSequence = 0;
state.previewChanges = [];
renderPreview();
statsNode.textContent = '';
for (const file of xmlInput.files) {
if (file.name.toLowerCase().endsWith('.zip')) {
await loadSourceZip(file);
} else {
await addSourceFile(file.name, await file.text());
}
}
renderFileStatus();
}
async function loadTargetTranslations(event) {
const file = event.target.files[0];
state.translationsByLanguage.clear();
state.targetFileName = '';
state.targetRowCount = 0;
state.targetHasLanguageColumn = false;
state.targetStats = createEmptyTargetStats();
resetObjectSelect();
if (!file) {
renderFileStatus();
return;
}
try {
const workbook = window.XLSX.read(await file.arrayBuffer());
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const data = window.XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: ''
});
if (data.length === 0) {
throw new Error('The target translation file is empty.');
}
const headers = data[0].map(normalizeHeader);
const objectIndex = findHeaderIndex(headers, ['object', 'objekt']);
const fieldIndex = findHeaderIndex(headers, ['field', 'feld']);
const targetIndex = findHeaderIndex(headers, ['target']);
const languageIndex = findHeaderIndex(headers, ['language', 'sprache']);
if (objectIndex < 0 || fieldIndex < 0 || targetIndex < 0) {
throw new Error('Required columns are missing. Use object, field, and target.');
}
state.targetHasLanguageColumn = languageIndex >= 0;
const objects = new Set();
const targetRows = data.slice(1).filter((row) => row.some((value) => String(value ?? '').trim() !== ''));
state.targetStats.totalRows = targetRows.length;
for (const row of targetRows) {
const objectName = String(row[objectIndex] ?? '').trim();
const fieldName = String(row[fieldIndex] ?? '').trim();
const targetValue = String(row[targetIndex] ?? '').trim();
const language = state.targetHasLanguageColumn ? normalizeLanguage(row[languageIndex]) : '';
if (!objectName || !fieldName || (state.targetHasLanguageColumn && !language)) {
state.targetStats.incomplete++;
continue;
}
if (!targetValue) {
state.targetStats.withoutTarget++;
continue;
}
if (fieldName === targetValue) {
state.targetStats.sameFieldAndTarget++;
continue;
}
if (!state.translationsByLanguage.has(language)) {
state.translationsByLanguage.set(language, new Map());
}
const key = objectName + '|' + fieldName;
const languageTranslations = state.translationsByLanguage.get(language);
if (languageTranslations.has(key)) {
state.targetStats.duplicates++;
}
languageTranslations.set(key, targetValue);
objects.add(objectName);
}
state.targetRowCount = [...state.translationsByLanguage.values()].reduce((total, entries) => total + entries.size, 0);
state.targetStats.usableUniqueRows = state.targetRowCount;
state.targetFileName = file.name;
populateObjectSelect(objects);
renderFileStatus();
} catch (error) {
state.translationsByLanguage.clear();
targetInput.value = '';
renderFileStatus();
window.alert(error.message);
}
}
async function analyze() {
await state.sourceLoadPromise;
if (state.sourceFiles.filter((entry) => !entry.error).length === 0) {
window.alert('Select at least one valid Salesforce XLIFF source file.');
return;
}
if (state.translationsByLanguage.size === 0) {
window.alert('Select a valid target translation file.');
return;
}
if (genericSourceSelectionIsMissing()) {
window.alert('Select the target XLIFF file for this target translation.');
return;
}
const matchedSources = state.sourceFiles.filter(getTranslationsForSource);
if (matchedSources.length === 0) {
window.alert('No Salesforce XLIFF source language matches the target translation data.');
return;
}
state.previewChanges = [];
const selectedObject = objectSelect.value;
const allTargetRows = new Set();
const rowsFoundInSource = new Set();
const rowsSameAsSource = new Set();
const rowsSameAsTarget = new Set();
const rowsToCreate = new Set();
const rowsToReplace = new Set();
state.translationsByLanguage.forEach((entries, language) => {
entries.forEach((value, key) => {
allTargetRows.add(language + '|' + key);
});
});
for (const source of matchedSources) {
const xml = parseXml(source.text);
const match = getTranslationsForSource(source);
const units = xml.getElementsByTagName('trans-unit');
for (const unit of units) {
const details = getUnitDetails(unit);
if (!details) {
continue;
}
const key = details.objectName + '|' + details.fieldName;
if (!match.translations.has(key)) {
continue;
}
const rowToken = match.languageKey + '|' + key;
rowsFoundInSource.add(rowToken);
const targetNode = unit.getElementsByTagName('target')[0];
const sourceNode = unit.getElementsByTagName('source')[0];
const oldValue = targetNode ? targetNode.textContent : '';
const sourceValue = sourceNode ? sourceNode.textContent : '';
const newValue = match.translations.get(key);
if (sourceValue.trim() === newValue) {
rowsSameAsSource.add(rowToken);
continue;
}
if (targetNode && oldValue.trim() === newValue) {
rowsSameAsTarget.add(rowToken);
continue;
}
if (targetNode) {
rowsToReplace.add(rowToken);
} else {
rowsToCreate.add(rowToken);
}
if (selectedObject && details.objectName !== selectedObject) {
continue;
}
state.previewChanges.push({
file: source.name,
language: source.languageLabel,
object: details.objectName,
field: details.fieldName,
oldValue: oldValue,
newValue: newValue
});
}
}
const rowsNotInSource = [...allTargetRows].filter((rowToken) => !rowsFoundInSource.has(rowToken)).length;
const translationsToApply = new Set([...rowsToCreate, ...rowsToReplace]).size;
renderPreview();
statsNode.innerHTML =
'<strong>Salesforce source comparison</strong><br>' +
escapeHtml(rowsFoundInSource.size) + ' target rows found in matched source files<br>' +
escapeHtml(rowsNotInSource) + ' target rows not present in matched source files<br>' +
escapeHtml(rowsSameAsSource.size) + ' target rows identical to the XLIFF source value<br>' +
escapeHtml(rowsSameAsTarget.size) + ' target rows identical to the current XLIFF target value<br>' +
escapeHtml(translationsToApply) + ' translations to apply<br>' +
escapeHtml(rowsToReplace.size) + ' existing targets to replace<br>' +
escapeHtml(rowsToCreate.size) + ' new targets to create';
}
async function exportZip() {
await state.sourceLoadPromise;
if (state.sourceFiles.filter((entry) => !entry.error).length === 0) {
window.alert('Select at least one valid Salesforce XLIFF source file.');
return;
}
if (state.translationsByLanguage.size === 0) {
window.alert('Select a valid target translation file.');
return;
}
if (genericSourceSelectionIsMissing()) {
window.alert('Select the target XLIFF file for this target translation.');
return;
}
const matchedSources = state.sourceFiles.filter(getTranslationsForSource);
if (matchedSources.length === 0) {
window.alert('No Salesforce XLIFF source language matches the target translation data.');
return;
}
const zip = new window.JSZip();
let exportedFileCount = 0;
for (const source of matchedSources) {
const xml = parseXml(source.text);
const match = getTranslationsForSource(source);
const units = Array.from(xml.getElementsByTagName('trans-unit'));
let appliedTranslationCount = 0;
for (const unit of units) {
const details = getUnitDetails(unit);
const key = details ? details.objectName + '|' + details.fieldName : '';
if (!details || !match.translations.has(key)) {
removeUnit(unit);
continue;
}
const newValue = match.translations.get(key);
let targetNode = unit.getElementsByTagName('target')[0];
const sourceNode = unit.getElementsByTagName('source')[0];
const sourceValue = sourceNode ? sourceNode.textContent : '';
if (sourceValue.trim() === newValue || (targetNode && targetNode.textContent.trim() === newValue)) {
removeUnit(unit);
continue;
}
if (!targetNode) {
targetNode = xml.createElement('target');
unit.appendChild(targetNode);
}
targetNode.textContent = newValue;
appliedTranslationCount++;
}
if (appliedTranslationCount === 0) {
continue;
}
zip.file(source.name, new XMLSerializer().serializeToString(xml));
exportedFileCount++;
}
if (exportedFileCount === 0) {
window.alert('No matching translations were found for the selected Salesforce XLIFF export files.');
return;
}
const blob = await zip.generateAsync({ type: 'blob' });
const link = document.createElement('a');
const downloadUrl = URL.createObjectURL(blob);
link.href = downloadUrl;
link.download = 'salesforce_translated_xliff.zip';
link.click();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
}
async function extractCsv() {
await state.sourceLoadPromise;
if (state.sourceFiles.filter((entry) => !entry.error).length === 0) {
window.alert('Select at least one valid Salesforce XLIFF source file.');
return;
}
const rows = collectExtractionRows();
if (rows.length === 0) {
window.alert('No translatable Salesforce rows were found in the selected XLIFF files.');
return;
}
downloadCsv('salesforce_translation_template.csv', rows);
}
xmlInput.addEventListener('change', () => {
state.sourceLoadPromise = loadSourceFiles();
});
targetInput.addEventListener('change', loadTargetTranslations);
genericSourceSelect.addEventListener('change', (event) => {
state.selectedGenericSourceId = event.target.value;
renderFileStatus();
});
analyzeBtn.addEventListener('click', analyze);
exportBtn.addEventListener('click', exportZip);
extractBtn.addEventListener('click', extractCsv);
renderFileStatus();
renderPreview();
if (options && options.standalone) {
document.body.classList.add('salesforce-translation-import-page');
}
}
window.initSalesforceTranslationImportApp = function initSalesforceTranslationImportApp(host, options) {
if (!(host instanceof HTMLElement)) {
return;
}
if (!window.XLSX || !window.JSZip) {
host.innerHTML = '<div class="salesforce-translation-import"><div class="salesforce-translation-import__card"><h2>Abhaengigkeiten fehlen</h2><p>Die benoetigten Bibliotheken fuer Excel- und ZIP-Verarbeitung konnten nicht geladen werden.</p></div></div>';
return;
}
buildApp(host, options || {});
};
}());

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
return [
'app_id' => 'salesforce-translation-import',
'title' => (string) ($manifest['title'] ?? 'Salesforce Translation Import'),
'icon' => 'SF',
'entry_route' => '/apps/salesforce-translation-import',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1180,
'default_height' => 820,
'supports_widget' => false,
'supports_tray' => false,
'module_name' => 'integration',
'summary' => (string) ($manifest['description'] ?? 'Import-Werkzeug fuer Salesforce-XLIFF-Dateien und Zieluebersetzungen aus Excel oder CSV.'),
'native_module' => [
'initializer' => 'initSalesforceTranslationImportApp',
'options' => [],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=salesforce-translation-import&path=assets/css/app.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/css/app.css') ? filemtime(__DIR__ . '/assets/css/app.css') : '0'))],
],
'scripts' => [
['src' => 'https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js'],
['src' => 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js'],
['src' => '/module-assets/index.php?module=salesforce-translation-import&path=assets/js/app.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/js/app.js') ? filemtime(__DIR__ . '/assets/js/app.js') : '0'))],
],
],
],
];

View File

@@ -0,0 +1,14 @@
# Salesforce Translation Import
Desktop-App fuer den Import von Salesforce-XLIFF-Dateien mit Zieluebersetzungen aus Excel oder CSV.
## Einbindung
- Desktop-App ueber `custom/apps/salesforce-translation-import/desktop.php`
- oeffentlicher App-Einstieg ueber `public/apps/salesforce-translation-import/index.php`
- Darstellung als `native-module` mit modul-eigenem `app.js` und `app.css`
## Hinweis
- die bestehende HTML-/JS-Logik aus `Customimport/salesforcelanguageimport.html` wurde in modul-eigene Assets ueberfuehrt
- die App nutzt weiterhin nur clientseitige Verarbeitung fuer Excel-, CSV-, ZIP- und XLIFF-Dateien

View File

@@ -0,0 +1,12 @@
{
"name": "salesforce-translation-import",
"title": "Salesforce Translation Import",
"version": "0.1.0",
"description": "Import-Werkzeug fuer Salesforce-XLIFF-Dateien und Zieluebersetzungen aus Excel oder CSV.",
"enabled_by_default": true,
"auth": {
"required": true,
"users": [],
"groups": []
}
}

Some files were not shown because too many files have changed in this diff Show More