diff --git a/src/App.php b/src/App.php index cc1af5a..b82a562 100644 --- a/src/App.php +++ b/src/App.php @@ -40,7 +40,7 @@ final class App $this->triggerReminderCheckFromTraffic($method, $path); $hasUsers = $this->users->hasAnyUsers(); $isAuthenticated = $this->auth->check(); - $systemPaths = ['/reminders/run', '/api/health']; + $systemPaths = ['/reminders/run', '/api/health', '/api/putzliga']; // A failed setup must never leave the app in a half-authenticated redirect loop. if (!$hasUsers && $isAuthenticated) { @@ -157,6 +157,14 @@ final class App $this->handleHealthImportStatus(); return; + case '/api/putzliga': + if ($method !== 'POST') { + json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405); + } + + $this->handlePutzligaImport(); + return; + default: http_response_code(404); View::render('not-found', [ @@ -348,6 +356,81 @@ final class App ]); } + private function handlePutzligaImport(): void + { + $token = $this->healthImportBearerToken(); + if ($token === '') { + json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401); + } + + $user = $this->users->findByPutzligaImportToken($token); + if ($user === null) { + json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401); + } + + $payload = $this->decodeHealthImportPayload((string) file_get_contents('php://input')); + $date = (string) ($payload['date'] ?? ''); + $tasksPayload = is_array($payload['tasks'] ?? null) ? $payload['tasks'] : []; + $tasks = array_values(array_filter(array_map( + static fn (mixed $task): string => trim((string) $task), + $tasksPayload + ), static fn (string $task): bool => $task !== '')); + + if (!$this->isValidDate($date)) { + json_response(['ok' => false, 'message' => 'Datum fehlt oder ist ungültig.'], 400); + } + + if (count($tasks) < 3) { + json_response(['ok' => false, 'message' => 'Mindestens 3 erledigte Aufgaben sind nötig.'], 400); + } + + $username = (string) ($user['username'] ?? ''); + $settings = $this->hydrateSettings($this->settings->forUser($username)); + $entries = $this->entries->all($username); + $entryMap = []; + foreach ($entries as $entry) { + if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) { + $entryMap[(string) $entry['date']] = $entry; + } + } + + $entry = $entryMap[$date] ?? $this->scoring->normalize([ + 'date' => $date, + 'pain_enabled' => !empty($settings['tracking']['pain_enabled']), + 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), + 'summary' => ['comment' => '', 'mood' => 0, 'energy' => 0, 'stress' => 0, 'alcohol' => false], + 'events' => [], + 'background_image' => '', + ]); + + $importID = 'putzliga-' . $date; + $events = array_values(array_filter( + is_array($entry['events'] ?? null) ? $entry['events'] : [], + static fn (array $event): bool => (string) ($event['import_id'] ?? '') !== $importID + )); + $events[] = [ + 'id' => $importID, + 'type' => 'event', + 'time' => (string) ($payload['time'] ?? ''), + 'comment' => 'Du warst fleißig', + 'value' => 0, + 'unit' => '', + 'mood' => 0, + 'energy' => 1, + 'stress' => 0, + 'source' => 'putzliga', + 'import_id' => $importID, + 'task_titles' => array_slice(array_values(array_unique($tasks)), 0, 20), + ]; + + $entry['events'] = $events; + $entryMap[$date] = $entry; + $this->persistUserEntries($username, $settings, array_values($entryMap)); + $this->users->recordPutzligaImport($username, 'ok', count($tasks) . ' Aufgaben synchronisiert.'); + + json_response(['ok' => true, 'message' => 'Putzliga-Moment aktualisiert.', 'tasks' => count($tasks)]); + } + private function healthImportBearerToken(): string { $header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '')); @@ -2394,6 +2477,14 @@ final class App $healthImportToken = null; } + $pendingPutzligaTokens = is_array($_SESSION['_putzliga_import_token'] ?? null) ? $_SESSION['_putzliga_import_token'] : []; + $putzligaImportToken = $pendingPutzligaTokens[$user['username']] ?? null; + if (is_string($putzligaImportToken)) { + unset($_SESSION['_putzliga_import_token'][$user['username']]); + } else { + $putzligaImportToken = null; + } + $optionsOpenPanel = trim((string) ($_GET['panel'] ?? '')); if ($optionsOpenPanel === 'score') { $optionsOpenPanel = ''; @@ -2414,6 +2505,9 @@ final class App 'healthImportConfig' => $this->users->healthImportConfig($user['username']), 'healthImportToken' => $healthImportToken, 'healthImportUrl' => app_origin() . '/api/health', + 'putzligaImportConfig' => $this->users->putzligaImportConfig($user['username']), + 'putzligaImportToken' => $putzligaImportToken, + 'putzligaImportUrl' => app_origin() . '/api/putzliga', 'backupAvailable' => class_exists('ZipArchive'), 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, @@ -2499,6 +2593,19 @@ final class App redirect('/options?panel=health'); } + if ($form === 'putzliga_import_token') { + $token = $this->users->issuePutzligaImportToken($user['username']); + $_SESSION['_putzliga_import_token'][$user['username']] = $token; + flash('success', 'Der Putzliga-Token wurde erstellt. Kopiere ihn in Putzliga.'); + redirect('/options?panel=health'); + } + + if ($form === 'putzliga_import_revoke') { + $this->users->revokePutzligaImportToken($user['username']); + flash('success', 'Der Putzliga-Token wurde deaktiviert.'); + redirect('/options?panel=health'); + } + if ($form === 'password') { $current = (string) ($_POST['current_password'] ?? ''); $new = (string) ($_POST['new_password'] ?? ''); diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index 5496a04..62c5889 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -221,6 +221,8 @@ final class EntryRepository $eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0); $eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0); $eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 0); + $taskTitles = is_array($event['task_titles'] ?? null) ? array_values(array_filter($event['task_titles'], 'is_string')) : []; + $eventLines[] = '- Aufgaben: ' . ($taskTitles !== [] ? base64_encode((string) json_encode($taskTitles, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) : ''); $route = is_array($event['route'] ?? null) ? $event['route'] : []; $eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : ''); $eventLines[] = ''; @@ -348,6 +350,7 @@ final class EntryRepository 'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0), 'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\s*(.*)$/mu', $block) ?? 0), 'sleep_core' => (float) ($this->extract('/^- Kernschlaf:\s*(.*)$/mu', $block) ?? 0), + 'task_titles' => $this->decodeStringList((string) ($this->extract('/^- Aufgaben:\s*(.*)$/mu', $block) ?? '')), 'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')), ]; } @@ -373,6 +376,29 @@ final class EntryRepository return $base; } + private function decodeStringList(string $encoded): array + { + $encoded = trim($encoded); + if ($encoded === '') { + return []; + } + + $decoded = base64_decode($encoded, true); + if ($decoded === false) { + return []; + } + + $items = json_decode($decoded, true); + if (!is_array($items)) { + return []; + } + + return array_values(array_filter(array_map( + static fn (mixed $item): string => trim((string) $item), + $items + ), static fn (string $item): bool => $item !== '')); + } + private function extractSection(string $content, string $startHeading, string $endHeading): ?string { $pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su'; diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 97d3f76..375c560 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -484,6 +484,10 @@ final class ScoringService 'sleep_deep' => max(0.0, min(24.0, is_numeric($event['sleep_deep'] ?? null) ? (float) $event['sleep_deep'] : 0.0)), 'sleep_rem' => max(0.0, min(24.0, is_numeric($event['sleep_rem'] ?? null) ? (float) $event['sleep_rem'] : 0.0)), 'sleep_core' => max(0.0, min(24.0, is_numeric($event['sleep_core'] ?? null) ? (float) $event['sleep_core'] : 0.0)), + 'task_titles' => array_values(array_slice(array_filter(array_map( + static fn (mixed $title): string => trim((string) $title), + is_array($event['task_titles'] ?? null) ? $event['task_titles'] : [] + ), static fn (string $title): bool => $title !== ''), 0, 20)), 'route' => $this->normalizeRoute($event['route'] ?? []), ]; } diff --git a/src/Domain/UserRepository.php b/src/Domain/UserRepository.php index fec6014..c9fca9d 100644 --- a/src/Domain/UserRepository.php +++ b/src/Domain/UserRepository.php @@ -171,6 +171,40 @@ final class UserRepository ]; } + public function findByPutzligaImportToken(string $token): ?array + { + $tokenHash = hash('sha256', $token); + + foreach ($this->all() as $user) { + $config = $user['putzliga_import'] ?? null; + + if (!is_array($config) || empty($config['enabled'])) { + continue; + } + + if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) { + return $user; + } + } + + return null; + } + + public function putzligaImportConfig(string $username): array + { + $user = $this->find($username); + $config = is_array($user['putzliga_import'] ?? null) ? $user['putzliga_import'] : []; + + return [ + 'enabled' => !empty($config['enabled']), + 'token_prefix' => (string) ($config['token_prefix'] ?? ''), + 'created_at' => (string) ($config['created_at'] ?? ''), + 'last_import_at' => (string) ($config['last_import_at'] ?? ''), + 'last_status' => (string) ($config['last_status'] ?? ''), + 'last_message' => (string) ($config['last_message'] ?? ''), + ]; + } + public function issueHealthImportToken(string $username): string { $token = 'mood_health_' . bin2hex(random_bytes(24)); @@ -236,6 +270,89 @@ final class UserRepository } } + public function issuePutzligaImportToken(string $username): string + { + $token = 'mood_putzliga_' . bin2hex(random_bytes(24)); + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized) { + continue; + } + + $currentConfig = is_array($user['putzliga_import'] ?? null) ? $user['putzliga_import'] : []; + $user['putzliga_import'] = [ + 'enabled' => true, + 'token_hash' => hash('sha256', $token), + 'token_prefix' => substr($token, 0, 20), + 'created_at' => date(DATE_ATOM), + 'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''), + 'last_status' => (string) ($currentConfig['last_status'] ?? ''), + 'last_message' => (string) ($currentConfig['last_message'] ?? ''), + ]; + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if (!$updated) { + throw new RuntimeException('Der Putzliga-Token konnte keinem Benutzer zugeordnet werden.'); + } + + $this->write(['users' => $users]); + + return $token; + } + + public function revokePutzligaImportToken(string $username): void + { + $normalized = normalize_username($username); + $users = $this->all(); + $updated = false; + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized || !array_key_exists('putzliga_import', $user)) { + continue; + } + + unset($user['putzliga_import']); + $user['updated_at'] = date(DATE_ATOM); + $updated = true; + break; + } + unset($user); + + if ($updated) { + $this->write(['users' => $users]); + } + } + + public function recordPutzligaImport(string $username, string $status, string $message): void + { + $normalized = normalize_username($username); + $users = $this->all(); + + foreach ($users as &$user) { + if (($user['username'] ?? '') !== $normalized) { + continue; + } + + $config = is_array($user['putzliga_import'] ?? null) ? $user['putzliga_import'] : []; + $config['last_import_at'] = date(DATE_ATOM); + $config['last_status'] = $status; + $config['last_message'] = substr($message, 0, 240); + $user['putzliga_import'] = $config; + $user['updated_at'] = date(DATE_ATOM); + break; + } + unset($user); + + $this->write(['users' => $users]); + } + public function recordHealthImport(string $username, string $status, string $message): void { $normalized = normalize_username($username); diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index e4c00f0..64df0a6 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -114,6 +114,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string { + @@ -184,6 +185,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string { 'sleep_deep' => (float) ($item['sleep_deep'] ?? 0), 'sleep_rem' => (float) ($item['sleep_rem'] ?? 0), 'sleep_core' => (float) ($item['sleep_core'] ?? 0), + 'task_titles' => is_array($item['task_titles'] ?? null) ? $item['task_titles'] : [], ]); ?> @@ -220,6 +222,14 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {

+ +
+ + + +
+ + 0): ?>

Lege in Health Auto Export zwei REST-API-Automationen an: Gesundheitsmetriken mit step_count und sleep_analysis, sowie Workouts mit JSON Version 2 und Routendaten.

+
+

Putzliga

+
+ + +
+

Putzliga erstellt ab 3 erledigten Aufgaben pro Tag einen Moment „Du warst fleißig“ und aktualisiert ihn, wenn weitere Aufgaben dazukommen.

+ + + +
+
Token
+
Letzter Sync
+
Statusmeldung
+
+
+ + + +
+ +
+ + + +
+ +
+

Neuer Token