Files
aza/AzA march 2026 - Kopie (12)/todo_server.py
2026-04-16 13:32:32 +02:00

1260 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
AzA To-Do Server Mobile PWA mit Supabase Cloud-Sync
======================================================
Die App synchronisiert sich direkt mit Supabase (kostenlose Cloud-DB).
Das iPhone braucht KEINEN laufenden PC funktioniert von ueberall.
Der lokale Server wird NUR einmalig benoetigt, um die App per QR-Code
auf das iPhone zu laden. Danach laeuft die App eigenstaendig.
Nutzung:
py todo_server.py Einmalig starten, QR scannen, fertig.
"""
import os
import sys
import json
import socket
import subprocess
import threading
import time
import webbrowser
import base64
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from aza_tls import check_tls_or_exit, create_ssl_context, has_tls_config
# ─── Konfiguration ───
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TODO_FILE = os.path.join(BASE_DIR, "kg_diktat_todos.json")
PORT = 5111
_APP_VERSION = "1.0.0"
_START_TIME = time.time()
SUPABASE_URL = "https://cqbqkejdlxesxjdtkpfr.supabase.co"
SUPABASE_ANON_KEY = "sb_publishable_r8YMw01FF-fYD9vHLnz7FQ_d8mhpVVQ"
HAS_QRCODE = False
try:
import qrcode
import io as _io
HAS_QRCODE = True
except ImportError:
pass
# ─── Logo ───
LOGO_PNG_FILE = os.path.join(BASE_DIR, "assets", "logo.png")
LOGO_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAIAAAC3ytZVAAAQAElEQVR4AexaC3xcVZ0+59z3vfPM"
"o2lpabElUAoqKMqislJ8wCrIS0RZF1CR7uJzFRXXVVf3p4JvXRdbFoQiFXnUri2PQi190KZpmqRJ"
"mzZpkjZp0zRNmzTPeWXm3rPfnWkmM7kzyc00XSl4+p8z53zn//yuuY+5KbP+1kYZeKlnhJG/tQwG"
"Xtt00IxM/1+GSTomiEonWDv9CfLTHwIRMkpkBJMJovIJ1uDpdSEZJTKSMXk9FIejewplsAJtETUl"
"mfZukEz9zDFsM6cFj3F04QpSkIdMOqbiA1FTkhnVDZKpnzmGbeY059hlgnAFyfTg0pCQTDrG+cj0"
"99oYF5yga8NMOl4bNf9Vszij6HB9kAum9IyigxZcZn7DbJ9pOrLh/Ob2c0rOVTiA5Fw6c8A0Ha43"
"YkoRlUOcdY4DMYU41cYhOXWcoBOBHyfoRKCWT1LljK6m6RgFXH7DCyRTGdOUOMFMJOcYhk7cCToR"
"WI0DwcU4BDquZRI6XPt5zSieAheo4Yyiw1mqE0FNpyDTSseUksOuHpe3Exmn4JwWYOJ0koEURkee"
"LPLAGeEmHE7KZj7/+fAJo+VcLIyOSRPPGSsJplOf1EdaM2mXtytYLZdhYXTkzS3HwrioPNcbhXE6"
"8ALEyRdALI0TqEHGgeOmMJxUJ2kynXTkjuhAkVsydEbn0MnFGckNZrjJO3T6h2ouMB8dozmPfsN8"
"nOA9GU+3ZKa2bvLgo0MsiG2S+rLX7NnoZ3T+1335OJpN+jsfHaki8h4Q8EC4RbnFGGeMhWKJzv7B"
"Syf6Dw8MDESiIIdRSjm3wFmqcPhLDRAZY2hgAIEC+lySVs+1eLqwfHTkjceTDeWgXosJnf3hVxpb"
"1+1re6np4BNV9U/X7F5Tv3dd04HmnhMjFhcyf+HYLCTdTl6orZFWT9qcts4ONeZ8CnTYp0AyR84"
"JY2RwJP787n1LN1U8Vbvr5ZbWHZ2H2gb76o92r93X8seanUu3VD5Tv2e7lCIUlJljAXON4NXCZks"
"J5xaxgNhMZueay9Q15s7VFOhIRgYVhDJ6NBx6tmbnmj2Nh8NRrigjViJOrPJ558woLbUEMcLJ0YH"
"h53fteWx7bXvfIKOCbZa0H9ehbLDMKBEoFSizhaHHiWZx2GCZTFPL5yobd0uHbWV/CKNkKDby4u7"
"m2q5ub8Dn8RpMFlVDl3XP8VDIZMwXDGoer9fnUXW9ur3jf3fu6g9Faa6Dw7mFrcMoLj0jB3r6qto"
"7K1rb6w93HR0KxRkDDgWQRXLZThNJ4924pYPgWKVsKdl39FhD1zFJ0QQqyJKo6ZqqG6phRDk1KVM"
"V1dBUWVUCPl+JP9DS09t8vIfieJOxBmI554zScMLa2Ny+bMuOJ3bserKm7uHtVUsrtj24ceuT22vb"
"Tgww7BhbdczwdI+Yzb0b+mlKiYZHEh19A0xgXs2QNVXzGCgep4+ZsBhloiipqiwpMhVEHHpdU3GU"
"2/v7RkzMMmrB1YeQvlj8z3UNT9fsrOvqbu09HhNoWdnMSII0dfe83ND4+NbKxmM9lDEQl2GZPQRZ"
"2cApzqbyZ6ckI5F4PJJICIKoK7Kuq4IgUMJExiRRUGRRkgQmMEmWJE1RNBXMJHAdGYzETJOmNwi4"
"oCQUjz9Xt2frwUOSYfh8Ht3rFRWVMNnAGRgIGv7gwf7BZ3bUNPf2MWozwu3jRsa31DEajxY+ZzlM"
"88TghFAscdITiYVMS8MlA4fOsqggGJpmKJIGFgTKGBVFwdB1j64FAz5d1ykTJAbeOaxJ6lGG0gN9"
"Aw1d3YKEs0pRdc3w+4ksD1sJUVU0bDhD9xcHukKRukOH4/YlBqaID+vTK0k6MgMhLrE/6bBYhGB"
"qo5x4ZNmySOuJQUESPIqoajIuFAKjkiKBBWwPe1tgxLlPErBBhuKJIkNRBCF18bFdUdxIrWMDg1x"
"kQa9H0VW4sKmiFHtNkkVFk0VZYpIkKupAJBoZiRM7NlLIJc6lnIgTzOUsScd4BTvnNJbpB3c/VRL"
"fPW92f//Qmn2HhjgrL/EvKPb6dU1XZFUSdIkV6/KcoK+uVxMkceOe1vaO4xefNRP1JJ3azuwPJwP"
"RKBckj6aKAs4FIkmSIsmKLAkCE2VJMzRdg8gJyxwxE6ktlU4JrkAfemL7SsOjA3thdJz6BgJJjSf"
"sHXTADJJtg6AQPE6hKtOyLpk76+Z5c+pe2LZ8Xe2m9p4joRFdU+YGjfKZReeWFQUMNRSPNRzvf6"
"pq784tddcvmLdwRike1wmMCaqgcE8pHUmYA9ERjyJ7ZEmSRFXGnpCxwWRZwpmlCEKR1yPJKmVMla"
"SMdLDJOCPUFgqvE11nM6zcDplbRejZlOCLSJR+evFld196wbG1W5c/svqXqzb9bsPO31fsXVnTur"
"K69bdrq379XMWjz29rWV9119sW3XHVZfZlgxBKbB4IGieU0gtmzOjo7d87MDyrtHhhaVGpKnpVwV"
"BZkUeZV+yf4zdIgu852BXUVPv0TFqBCWrnQAcika6+gVDMxJXKZher0yTu6OCj0ajdLE4CAc99n7"
"nxO7e9b/6Jnr6XKrY9unrNfz+7YvmaP6zeuG1nU09H15zI8Dc++K4vfuJDHlXFEaR2GUkn1OYCh"
"S2cVfrOoH/tn19du+tAt2kZXmPujJLzymbMDvhjnDYcG3jmlapAOPbuBfNhjvi2HaPdQ9EXdjc9UV"
"O3orZheWX11v0H46aV9JvRQTtjZg+diI3m+Lijg9qW6CB2NZRYFjcMbcnt1y//2b3f//S1t14y/4"
"pi/RIaf2+pcfdlFzxww1W/+8Jtn//kdbg0WLgvnCTDtra3iH0l4IooLrny8quD/hcffub+pc8sW/"
"Pqik01K6sbH95Q/fOVrzz+x5f8fYNfv/qKOT4PuKN2VN4fjq5uaHy+ZX/zcOQYJ3W9fU/W1G9v7"
"4A/zjNIoXa2WR8nkrU8NnFHx5g+sT1TysAIIYzSi8vf9MXbb/zND7+84sFvPfnAVx763G0//Og1"
"n1182aUL3iRSyjmnlBKabU8BEtMyzyoJfP+rt9933RXn7D/Y8tif1/3m6VWPP1e3rW6WFbvr7ef9"
"YsktFyw427JMioaKKd97rPtwaDhYXII7ToLzYDAo+zx13Z29kSjDjTwdJDNcGnQ3yE9HptPMMbGr"
"Q4Y0+W2aOPxWka6dXVpy/tw5582aGdC0hMVNK/kYSondxvYqRhC7OHiwLKvIo/7rXbc+/st/e+QH"
"n7v/szf+6KMfeOzOm5Z/9tb/vPPmhfNmwwmhyNA2QZhwgvt8fo+qUEYt3IJEVlZaTFV9OBYndjIk"
"b0ulkXd5bAHBxiZZIzuHUSBznMK4HR9RGHYIpRZ+k+M4oh4IIQJFwjSlmDo7kmNu9+gghFB7b9m"
"G+DV0/jlzbrj6vV+644Yvf+yaD1160YIZJXhRgvpt3+RkY1SIx2ODsfDZs8vOnVXyplL/grPKPIb"
"OE5YuySeVUjGT/k8iqS8nksIdfX46HKoTAJQSSm0K0NNMvew8MOP4iWoL58mzHfqYgcNEwkygmWb"
"CsiAW55TQUU8UhpTQN8+c2dt0aOPm2oRJNd3oGhjcvGFHqSWVerSUtwzqR02n+D09dEwcFMWkBMG"
"wm0YFM+wOTuyzigqM4XlMRM8oLjqM4oTCvuEwxIgSyi1+dtD/hfe9Z+bgwIt/eO75Vevb6vZ9uHz"
"+h996HvQIoQR6+JyaIKdTczChdaoaxGCUEk5jiUTvcKS9p6/20OFX9h3Y0Lz/1f0HX2na/9Ke5hca"
"GtfvbdnR1tl09Fj3UDg2YsECF0jbEHUmHVkWP3/+nO/edcuP7vjIfR98zw9uvPr29/+d18CNnIA1"
"8AFl9BNmNMkiUp1EI9fyhEGTixwFcMLgnvL+WKyxs/vFXY2/r9jxyKuVSzdVLN209dGKivXNB6qP"
"9KzZs29lfQPeJD65c/fvKqv/Z/OO32K1smptQ2Pj0WODeBFN4QdOObaIyS1Jkt5SPv/yi86bGfRh"
"aiIQxXMqwY/mvlAEjBPQRzj6AgT5urBCMllaeYIl1ZAexH4SZaQ3HNnWdviJ7bXLtlb+qWHPhvaO"
"ht6erkh4yDRVzRtKWPuPdw9zk+BnIRPCnISJdTgcajpxYlvHoZW79zy2rfqhiqpVdY1tx/stThn+"
"EQvXHCvVuH19wa5jlAxEYy/saX6oqvrJnbvwUg6aWSkiMUhWCbkn7ujI8p105PQOhOOg4MORX9S0"
"trV1PFZZ+3T1rtqOrgQTfV5/kcdnqJooiQGvX1XUkBkPcXPYMkVZfvu55RfMLOOWKcuywETTZLER"
"frh/uObQ0ZU19cs2bfnTrl0tvX2WyXAS2RnQ5NXCHhGTk8YTPS2RUEhV20KR9hP92Js0uTTWOUsY"
"WxsbsbHhlEZO7ynE7imO1UuNzc/W1DV0HY1aVmkwiNcfVMAjgiqraLqq6ZKCxweGykVJDVsJIhCf"
"z6PouiCrhtcnKWo4YRJBUFSFSKypp/eF+n1PVdZsaGkLxewXqSgYoVAztbcIG4mZNM5nBIrwEobE"
"CaWUYzldUeY4DeYauKbDjUdu5xE1TVwgcZkMmXyGvyTg8XHGomYiQbiJNAVBVBQLYQVmeD1FwUBR"
"UVDRdLqOztbeAc3QFVUWwZBhGHjgMrCV8CYJisaJ0HBz1/Hn6huwWQ71DlDKcJokdyPBq5SLZ5Wd"
"qyjR1rYLNW3RzFLcayioylXwxBjymlhhdHVS7xy5EWgNRMJ7jx7FMSzy+mRZlGRUJ+D9RemMUq9H"
"FwWqaxo44CIzCSFM0EQp6PEW+f2MUoEIXsMQFYkIVFZlSRKxkbCdDE0zdE2QpeF4fF1j01PVtR1g"
"BJdWbvvghPg09dq3X7hk8eXXv22hx1CAwHcBwgqwyW1CCUnKsXBkeCSu67pFSNxMoA4v3lyIIrW4"
"wATD8EiSgKctQZQEQcRjeCwWhWDLM1lAcYwJ4EuRZIFSVUZTYOUxPHNmnRUMBnRdo5K078ixmo6O"
"UAK7jYETglsJB72C12MQipdJSTaSyWBpSsKmpD2xMhJAIp2DQ/v7hyxKGLcYAwmWaZmarEiiaBIr"
"wU1MR2JR7CVRsDeOpMo47FygDLUbuoSLBaWmGcdlRtE0KBNC4TmB081MUEGYVVIiS9rh/qFwPA6c"
"ZDSLIz4hQCEYQshoAzI6zPrOxk+BjmxHCA1BJDxmt50Y7BgKS6IkSJRzS2CCpsiy/RIQ2yKuKkpp"
"UbHXoxNqW+AkEwXR0Az0Fhr2PyWqqnk8HhTPZIkpEhexcTgoFgXBSiTCsWj30PBANGZXjpCjMpaR"
"7XgUxTcWxiEAU5KNnwId2Y4QEf4poYtKi+cY3uqj/fW9uOEJmiR5FDx287hpUsq8Xi8uBBaOs2X5"
"PR6vx9BVVRIYEDyhS4IQT8Qpo7KmckYkUYSG5tF0v8cb8CuqahKQQhVZGohG+yMxQihxtuzE7HUn"
"YqM5PqdARw5vxDSt+cVFN11UTgcjGw8c2dTZ0xOOypTqAsFLHwDH4gAABhtJREFU4qDHKPbouiyh"
"CJFwhRGdUZ8s+jQZIMNUlYJej6ZIIjaOaf/twqMqCs6ykRHwgNtQ0B+QqNgbiXkUZY7XwEWD5GSE"
"FNimkw6UkMzCuuai8o+VzzH3H6lu7XpmX+ealiNt/WGR0rlBb5nXKwsCdoRu6BSPHYJILMosZl86"
"KFMEURUFXRINVZMlKRaLmSNxgIakGIwGZSmgqYfD4bbeofPLZpV5DW7hBErGzOxc74VMo9R4Oung"
"lFCGp2gSUOV7P3LlP1+6SKtvaV9ftWFz3YrtTc82HKw4dLxzKEwZLTLUMp9xdpHvrIBnZsAT9Kh+"
"TQ569YChGbIY0JQ5Rb6zSwMlXo9MuYzbkyhETbL9QMef6pteaWy/sKTk/QsXUOwNlit/LJACWy53"
"BboiqTQopaZFZgX83/vMjd+99X3l/T2JTdu6Xq7asqn+kQ21P12/46HK3av3Hqjv6ukOxyJQFehM"
"v2dRWdGimcWzQY0ORiSSSCTiiZhpHh8O7Tra+0Jr55N1+1dXNVXXtrx3Rum3r7nibL+O+wg9GbPQ"
"jGFH8RkTd3Rk25y0doI2QpEhpSRhckOR7rntQ0t/ft+nbrhqbmzQ2rhl8OUthzdV79i8a9WGXQ++"
"WPWTVZt+8tyWX6+vfnBDzcNb6h+vbFhe2fDYtt2PbKn71V8qf7Vu+7LNO5fv3L9qT8eW6uaWyt36"
"oSNLLlzws1s+WD6rxOScUmpnkuzsQfrjBkkrZ59Z7ujItjnpyglyUIFFSihleOIgBDeMK99c/otv"
"/cvDP/765/7xw28xRG9zK62oja/bFnp+c+eajS1/qaqv3ruuqv7ptVtXrH519Yad67fsXr9p54661"
"gPdfd1HTnRV7B5cu3lGc8udF56z7POf+N6nrp9dGsTDG0txgWgcn2xxg2RbpGfu6EiruxhQQiCEo"
"KeUMtxrNElY/I6LHvjm3U8//MNl93/1nluuunJh2bkaL46EpAMd5o49bP9hcSgkxaJ6POqzYqXUm"
"kXNBTxxQSh8TVD/yj9c/ui/3/2be++89l2X+DTFssgYF2Sa2/TTkUoQjJwUxixOQIpESfnsso9d/Z"
"4Hvrnk97/+zopl31/6s6//x713HPTVbddsvDjbzv/zr+/+J8uf/OSxe/8zkev/vFt1/3opg889MWP"
"/+GBL/34a5++6tKL8PwCJ9boKZKKMu396aIjnahNCiU4dwih2ORmwpQpmV0SuHTh/JuvfMc3br/u"
"51/71G+/efd/Lfn4T2659v6br/n2tYvvWXzZJy+/+LpLFr713HlBv4+DTftp1d4UDO5IRuMZ4+kY"
"nnY60kmiEIazR2CEEpObCTyH2ofbEvFILomGqhiqhLusIgqEcPxUSUqSBmITQSmB4fjyAZLpbGw6"
"nbnwhfwpoYwwgeIhjFGKGcVvXwtPnZxgYBdMUw1bKjlw4TZLhWbN7IkTsdEcn0LpcB0gR0wHBGeU"
"UErImFCKMRnXckDjNPJMbY7zLGXDhdLhOkB2ONeznP5zgk6XLtWchoQUSkcuX68DrFA6XB4Bl2o5"
"iTwV25wOXYCF0kFd+IaKSzVoOuVUbJ3e3CGF0uHO+xmnNa10uDyeTjUnAiKdoBOBmlOcakAgTk0H"
"Mq10uDzbnWpOBIk6QScCNac41YBAnJoOZFrpcHifHHB30Cb3M00a7uhwRy3+VjDlrPJ5Bg5xunOC"
"bhCnnzyIOzpcHkOXanlSyYIpITm9OUE3SJbriSbu6JjIwzStOauaJsdTcuOajsLSzWkFEDIuTeee"
"z1RI6aOHZOIYOxGA48SNTtJkMjrSjiZON+krR+e0Sjl04k7jlGYaT02dhk4kpZk2nMpgMjrgC94h"
"GEyLIHuIG1eZahinZKqGKX3YpgaT9RPSkWbBtbvJwr3W1yekI8VCqncWkg93agJJM4txwTKliAVF"
"GaUjZ7oAJ8gAqwWFLNzo9EccpSNn2TnBwqqZRlfjEnBy5ETGmWROs5VH6cjUOLPGTqKdSL6KsrmA"
"1plPB4ooQEAE3sZy+7/bZVq/UenADsLfbxy/st6odGBLYIOgz5Y3Jh1JJrBBwEVyiO+UvDHpSDGR"
"ZCBjSAj5PwAAAP//81mscgAAAAZJREFUAwCS9EjFwEhqfwAAAABJRU5ErkJggg=="
)
def _get_logo_bytes():
if os.path.isfile(LOGO_PNG_FILE):
with open(LOGO_PNG_FILE, "rb") as f:
return f.read()
return base64.b64decode(LOGO_PNG_B64)
def _load_todos():
try:
if os.path.isfile(TODO_FILE):
with open(TODO_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def _save_todos(todos):
try:
with open(TODO_FILE, "w", encoding="utf-8") as f:
json.dump(todos, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Save error: {e}")
def _supabase_push(todos=None):
"""Pusht Todos nach Supabase (Server-seitig)."""
import urllib.request
if todos is None:
todos = _load_todos()
payload = json.dumps({"data": todos}).encode("utf-8")
req = urllib.request.Request(
f"{SUPABASE_URL}/rest/v1/todo_sync?id=eq.1",
data=payload, method="PATCH",
headers={
"apikey": SUPABASE_ANON_KEY,
"Authorization": f"Bearer {SUPABASE_ANON_KEY}",
"Content-Type": "application/json",
}
)
try:
urllib.request.urlopen(req, timeout=10)
return True
except Exception:
return False
def _get_local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def _add_firewall_rule():
if sys.platform != "win32":
return
try:
subprocess.run(
["netsh", "advfirewall", "firewall", "add", "rule",
"name=AzA Todo Server (Port)", "dir=in", "action=allow",
"protocol=TCP", f"localport={PORT}"],
capture_output=True, timeout=5
)
except Exception:
pass
# ─── HTTP-Server (nur fuer Ersteinrichtung + lokale Nutzung) ───
from socketserver import ThreadingMixIn
class TodoServer(ThreadingMixIn, HTTPServer):
allow_reuse_address = True
daemon_threads = True
class TodoHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def _send_json(self, data, status=200):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_html(self, html, status=200):
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_text(self, text, content_type="text/plain", status=200):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_bytes(self, data, content_type, status=200):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "public, max-age=31536000")
self.end_headers()
self.wfile.write(data)
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
path = self.path.split("?")[0]
if path == "/health":
self._send_json({
"status": "ok",
"version": _APP_VERSION,
"uptime_s": int(time.time() - _START_TIME),
"tls": has_tls_config(),
})
elif path == "/":
self._send_html(_build_pwa_html())
elif path == "/api/todos":
self._send_json(_load_todos())
elif path == "/api/qr":
self._handle_qr()
elif path == "/manifest.json":
self._send_json({
"name": "AzA To-Do", "short_name": "AzA To-Do", "start_url": "/",
"display": "standalone", "background_color": "#E8F4FA",
"theme_color": "#1a4d6d",
"icons": [{"src": "/icon-180.png", "sizes": "180x180", "type": "image/png"}]
})
elif path in ("/icon-180.png", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png"):
self._send_bytes(_get_logo_bytes(), "image/png")
elif path == "/sw.js":
self._send_text(_build_sw_js(), "application/javascript")
else:
self.send_error(404)
def do_POST(self):
pass
def do_PUT(self):
pass
def do_DELETE(self):
pass
def _handle_qr(self):
if not HAS_QRCODE:
self._send_json({"error": "qrcode nicht installiert"}, 500)
return
scheme = "https" if has_tls_config() else "http"
url = f"{scheme}://{_get_local_ip()}:{PORT}"
img = qrcode.make(url, box_size=8, border=2)
buf = _io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
self._send_json({"qr": f"data:image/png;base64,{b64}", "url": url})
# ─── Service Worker ───
def _build_sw_js():
return '''
var CACHE_NAME = 'aza-todo-v5';
var URLS_TO_CACHE = ['/', '/manifest.json', '/icon-180.png'];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) { return cache.addAll(URLS_TO_CACHE); })
);
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(names) {
return Promise.all(
names.filter(function(n) { return n !== CACHE_NAME; })
.map(function(n) { return caches.delete(n); })
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', function(event) {
var url = event.request.url;
if (url.indexOf('supabase.co') !== -1) {
event.respondWith(fetch(event.request));
return;
}
event.respondWith(
caches.match(event.request).then(function(resp) {
return resp || fetch(event.request).then(function(netResp) {
return caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, netResp.clone());
return netResp;
});
}).catch(function() {
return new Response('<h1>Offline</h1>', {headers: {"Content-Type": "text/html"}});
});
})
);
});
'''
# ─── PWA HTML (nutzt Supabase direkt kein Server noetig!) ───
def _build_pwa_html():
return f'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#1a4d6d">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" href="/icon-180.png">
<title>AzA To-Do</title>
<style>
:root {{
--bg: #E8F4FA; --card: #fff; --header: #1a4d6d; --accent: #5B8DB3;
--light: #B9ECFA; --mid: #7EC8E3; --text: #1a4d6d; --muted: #4a8aaa;
--red: #E87070; --green: #5BDB7B; --yellow: #F0C040;
--prio1: #FDE8E8; --prio2: #FDF5E0; --overdue: #FDECEC; --done: #EDF7ED;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; min-height: 100dvh;
padding-top: var(--safe-top); padding-bottom: var(--safe-bottom);
-webkit-tap-highlight-color: transparent;
}}
header {{
background: var(--header); color: white; padding: 16px 20px 12px;
position: sticky; top: 0; z-index: 100;
padding-top: calc(12px + var(--safe-top));
box-shadow: 0 2px 12px rgba(0,0,0,.15);
}}
header h1 {{ font-size: 20px; font-weight: 700; display: flex; align-items: center; gap: 8px; }}
header .logo {{ width: 28px; height: 28px; border-radius: 6px; }}
header .sub {{ font-size: 12px; opacity: .7; margin-top: 2px; }}
.sync-dot {{ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--green); margin-left: 8px; vertical-align: middle; }}
.sync-dot.off {{ background: var(--red); }}
.sync-dot.syncing {{ background: var(--yellow); animation: pulse .8s infinite; }}
@keyframes pulse {{ 0%,100% {{ opacity:1; }} 50% {{ opacity:.3; }} }}
.input-bar {{
background: #D4EEF7; padding: 12px 16px; display: flex; gap: 8px;
position: sticky; top: 68px; z-index: 90;
}}
.input-bar input {{
flex: 1; border: none; border-radius: 12px; padding: 12px 16px;
font-size: 16px; background: white; color: var(--text);
outline: none; -webkit-appearance: none;
}}
.input-bar input::placeholder {{ color: #aaa; }}
.input-bar button {{
background: var(--accent); color: white; border: none; border-radius: 12px;
padding: 0 18px; font-size: 22px; font-weight: 700; cursor: pointer;
min-width: 48px; transition: background .15s;
}}
.input-bar button:active {{ background: var(--header); }}
.date-bar {{
background: #D4EEF7; padding: 0 16px 10px; display: flex; align-items: center;
gap: 8px; font-size: 13px; color: var(--muted);
}}
.date-bar input[type=date] {{
border: none; border-radius: 8px; padding: 6px 10px; font-size: 14px;
background: white; color: var(--text); -webkit-appearance: none;
}}
.date-bar .prio-btn {{
border: none; border-radius: 8px; padding: 5px 10px; font-size: 13px;
cursor: pointer; transition: all .15s;
}}
.date-bar .prio-btn.p0 {{ background: #E8F4FA; color: var(--muted); }}
.date-bar .prio-btn.p1 {{ background: var(--prio1); color: #A03030; }}
.date-bar .prio-btn.p2 {{ background: var(--prio2); color: #806000; }}
.counter {{ padding: 8px 20px; font-size: 12px; color: var(--muted); background: var(--bg); }}
.counter .overdue-count {{ color: var(--red); font-weight: 600; }}
.todo-list {{ padding: 0 12px 120px; }}
.todo-item {{
background: var(--card); border-radius: 14px; padding: 14px 16px;
margin: 8px 0; display: flex; align-items: flex-start; gap: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: all .2s;
border-left: 4px solid var(--mid); position: relative;
}}
.todo-item.prio1 {{ border-left-color: var(--red); background: var(--prio1); }}
.todo-item.prio2 {{ border-left-color: var(--yellow); background: var(--prio2); }}
.todo-item.overdue {{ border-left-color: var(--red); background: var(--overdue); }}
.todo-item.done {{ border-left-color: #A8D8A8; background: var(--done); opacity: .7; }}
.todo-item .check {{
width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--mid);
flex-shrink: 0; cursor: pointer; display: flex; align-items: center;
justify-content: center; font-size: 14px; color: transparent;
transition: all .2s; margin-top: 2px;
}}
.todo-item .check:active {{ transform: scale(.9); }}
.todo-item.done .check {{ background: var(--green); border-color: var(--green); color: white; }}
.todo-item .content {{ flex: 1; min-width: 0; }}
.todo-item .task-text {{ font-size: 15px; line-height: 1.4; word-break: break-word; }}
.todo-item.done .task-text {{ text-decoration: line-through; color: #999; }}
.todo-item .meta {{ display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; align-items: center; }}
.todo-item .meta span {{ font-size: 11px; padding: 2px 6px; border-radius: 6px; }}
.todo-item .date-tag {{ background: #D4EEF7; color: var(--muted); }}
.todo-item .date-tag.overdue {{ background: #FDE0E0; color: var(--red); font-weight: 600; }}
.todo-item .prio-tag {{ font-weight: 600; }}
.todo-item .prio-tag.p1 {{ background: #F8D0D0; color: #A03030; }}
.todo-item .prio-tag.p2 {{ background: #F8ECC8; color: #806000; }}
.todo-item .notes-indicator {{ color: var(--muted); font-size: 12px; }}
.todo-item .sender-tag {{ background: var(--light); color: var(--text); font-style: italic; }}
.todo-item .delete-btn {{
position: absolute; top: 8px; right: 12px; font-size: 18px; color: #ccc;
cursor: pointer; padding: 4px; line-height: 1; transition: color .15s;
}}
.todo-item .delete-btn:active {{ color: var(--red); }}
.footer-bar {{
position: fixed; bottom: 0; left: 0; right: 0;
background: #D4EEF7; padding: 10px 16px; padding-bottom: calc(10px + var(--safe-bottom));
display: flex; gap: 8px; justify-content: center;
box-shadow: 0 -2px 12px rgba(0,0,0,.08); z-index: 100;
}}
.footer-bar button {{
border: none; border-radius: 10px; padding: 10px 16px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: all .15s;
}}
.footer-bar .btn-del {{ background: #C8DDE6; color: var(--text); }}
.footer-bar .btn-del:active {{ background: #B0CCD6; }}
.footer-bar .btn-sync {{ background: var(--accent); color: white; }}
.footer-bar .btn-sync:active {{ background: var(--header); }}
.modal-overlay {{
display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4);
z-index: 200; align-items: flex-end; justify-content: center;
}}
.modal-overlay.show {{ display: flex; }}
.modal {{
background: var(--bg); border-radius: 20px 20px 0 0; width: 100%; max-width: 500px;
max-height: 85vh; overflow-y: auto; padding: 20px; padding-bottom: calc(20px + var(--safe-bottom));
animation: slideUp .25s ease-out;
}}
@keyframes slideUp {{ from {{ transform: translateY(100%); }} to {{ transform: translateY(0); }} }}
.modal h2 {{ font-size: 17px; color: var(--header); margin-bottom: 16px; }}
.modal label {{ display: block; font-size: 13px; font-weight: 600; color: var(--text); margin: 10px 0 4px; }}
.modal textarea, .modal input[type=text], .modal input[type=date] {{
width: 100%; border: 1px solid #D0E8F0; border-radius: 10px; padding: 10px 12px;
font-size: 15px; font-family: inherit; color: var(--text); background: white;
outline: none; -webkit-appearance: none; resize: vertical;
}}
.modal textarea:focus, .modal input:focus {{ border-color: var(--accent); }}
.modal .prio-group {{ display: flex; gap: 8px; margin-top: 4px; }}
.modal .prio-group button {{
flex: 1; border: 2px solid transparent; border-radius: 10px; padding: 8px;
font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s;
}}
.modal .prio-group button.active {{ border-color: var(--header); }}
.modal .prio-group .p0 {{ background: #E8F4FA; color: var(--muted); }}
.modal .prio-group .p1 {{ background: var(--prio1); color: #A03030; }}
.modal .prio-group .p2 {{ background: var(--prio2); color: #806000; }}
.modal .btn-row {{ display: flex; gap: 10px; margin-top: 18px; }}
.modal .btn-row button {{
flex: 1; border: none; border-radius: 12px; padding: 14px; font-size: 15px;
font-weight: 700; cursor: pointer; transition: all .15s;
}}
.modal .btn-save {{ background: var(--accent); color: white; }}
.modal .btn-save:active {{ background: var(--header); }}
.modal .btn-cancel {{ background: #C8DDE6; color: var(--text); }}
.empty-state {{ text-align: center; padding: 60px 20px; color: var(--muted); }}
.empty-state .icon {{ font-size: 48px; margin-bottom: 12px; }}
.empty-state p {{ font-size: 15px; }}
/* Tab Bar */
.tab-bar {{
display: flex; background: #A8D8E8; position: sticky; top: 52px; z-index: 95;
}}
.tab-bar .tab {{
flex: 1; text-align: center; padding: 10px 0; font-size: 14px; font-weight: 600;
cursor: pointer; transition: all 0.15s; color: #5A90B0;
background: #A8D8E8; border: none;
}}
.tab-bar .tab.active {{
background: var(--bg); color: var(--text);
}}
/* Notes */
.notes-page {{ display: none; }}
.notes-page.active {{ display: block; }}
.todos-page {{ display: block; }}
.todos-page.hidden {{ display: none; }}
.note-input-bar {{
background: #D4EEF7; padding: 12px 16px;
}}
.note-input-bar input {{
width: 100%; border: none; border-radius: 12px; padding: 12px 16px;
font-size: 16px; background: white; color: var(--text); outline: none;
-webkit-appearance: none; margin-bottom: 8px;
}}
.note-input-bar textarea {{
width: 100%; border: none; border-radius: 12px; padding: 12px 16px;
font-size: 15px; background: white; color: var(--text); outline: none;
-webkit-appearance: none; resize: vertical; font-family: inherit;
min-height: 60px;
}}
.note-input-bar button {{
background: var(--accent); color: white; border: none; border-radius: 12px;
padding: 10px 18px; font-size: 14px; font-weight: 700; cursor: pointer;
margin-top: 8px; float: right; transition: background .15s;
}}
.note-input-bar button:active {{ background: var(--header); }}
.note-item {{
background: var(--card); border-radius: 14px; padding: 14px 16px;
margin: 8px 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06);
border-left: 4px solid var(--mid); position: relative;
}}
.note-item .note-title {{
font-size: 15px; font-weight: 700; color: var(--text); cursor: pointer;
}}
.note-item .note-preview {{
font-size: 13px; color: var(--muted); margin-top: 4px; line-height: 1.4;
}}
.note-item .note-date {{
font-size: 11px; color: #B0C8D8; margin-top: 6px;
}}
.note-item .delete-btn {{
position: absolute; top: 8px; right: 12px; font-size: 18px; color: #ccc;
cursor: pointer; padding: 4px; line-height: 1; transition: color .15s;
}}
.note-item .delete-btn:active {{ color: var(--red); }}
.note-modal .modal {{ max-height: 90vh; }}
.note-modal textarea {{ min-height: 120px; }}
/* Drag & Drop */
.todo-item .drag-handle {{
font-size: 18px; color: #B0C8D8; cursor: grab; touch-action: none;
padding: 4px 2px; margin-right: 4px; flex-shrink: 0; user-select: none;
-webkit-user-select: none; display: flex; align-items: center;
}}
.todo-item .drag-handle:active {{ color: var(--accent); }}
.todo-item.dragging {{
opacity: 0.5; transform: scale(0.97);
box-shadow: 0 4px 16px rgba(0,0,0,.15);
}}
.drop-indicator {{
height: 4px; background: var(--accent); border-radius: 2px;
margin: 2px 12px; transition: all 0.15s;
}}
</style>
</head>
<body>
<header>
<h1>
<img class="logo" src="/apple-touch-icon.png" alt="AzA">
AzA To-Do
<span class="sync-dot syncing" id="syncDot" title="Sync"></span>
</h1>
<div class="sub" id="syncStatus">Verbinde...</div>
</header>
<div class="tab-bar">
<button class="tab active" id="tabTodo" onclick="switchTab('todo')">&#x1F4CB; Aufgaben</button>
<button class="tab" id="tabNotes" onclick="switchTab('notes')">&#x1F4DD; Notizen</button>
</div>
<div class="todos-page" id="todosPage">
<div class="input-bar">
<input type="text" id="newText" placeholder="Neue Aufgabe..." enterkeyhint="done">
<button onclick="addTodo()" aria-label="Hinzufuegen">+</button>
</div>
<div class="date-bar">
<span>Faellig:</span>
<input type="date" id="newDate">
<button class="prio-btn p0" id="newPrioBtn" onclick="cyclePrio()">Keine</button>
</div>
<div class="counter" id="counter"></div>
<div class="todo-list" id="todoList"></div>
<div class="footer-bar">
<button class="btn-del" onclick="deleteDone()">Erledigte loeschen</button>
<button class="btn-del" onclick="resetSort()">&#x2195; Auto-Sort</button>
<button class="btn-sync" onclick="syncNow()">&#x1F504; Sync</button>
</div>
</div>
<!-- Notizen-Seite -->
<div class="notes-page" id="notesPage">
<div class="note-input-bar">
<input type="text" id="noteTitle" placeholder="Notiz-Titel...">
<textarea id="noteText" rows="2" placeholder="Inhalt..."></textarea>
<button onclick="addNote()">+ Notiz</button>
<div style="clear:both"></div>
</div>
<div id="notesList" style="padding-bottom: 80px;"></div>
<div class="footer-bar">
<button class="btn-sync" onclick="syncNotesNow()">&#x1F504; Sync Notizen</button>
</div>
</div>
<!-- Notiz-Detail-Modal -->
<div class="modal-overlay note-modal" id="noteDetailModal">
<div class="modal">
<h2>Notiz bearbeiten</h2>
<label>Titel</label>
<input type="text" id="editNoteTitle">
<label>Inhalt</label>
<textarea id="editNoteText" rows="6"></textarea>
<div class="btn-row">
<button class="btn-cancel" onclick="closeNoteDetail()">Abbrechen</button>
<button class="btn-save" onclick="saveNoteDetail()">Speichern</button>
</div>
</div>
</div>
<div class="modal-overlay" id="detailModal">
<div class="modal">
<h2>Aufgabe bearbeiten</h2>
<label>Aufgabe</label>
<textarea id="editText" rows="2"></textarea>
<label>Notizen</label>
<textarea id="editNotes" rows="3"></textarea>
<label>Faellig</label>
<input type="date" id="editDate">
<label>Prioritaet</label>
<div class="prio-group">
<button class="p1" onclick="setEditPrio(1)">Hoch</button>
<button class="p2" onclick="setEditPrio(2)">Mittel</button>
<button class="p0" onclick="setEditPrio(0)">Keine</button>
</div>
<div class="btn-row">
<button class="btn-cancel" onclick="closeDetail()">Abbrechen</button>
<button class="btn-save" onclick="saveDetail()">Speichern</button>
</div>
</div>
</div>
<script>
var todos = [];
var newPrio = 0;
var editIdx = -1;
var editPrio = 0;
var SYNC_INTERVAL = 6000;
var LS_KEY = 'aza_todos_v2';
/* ─── Supabase Config (eingebettet vom Server) ─── */
var SUPA_URL = '{SUPABASE_URL}';
var SUPA_KEY = '{SUPABASE_ANON_KEY}';
function setSyncState(state, text) {{
var dot = document.getElementById('syncDot');
var lbl = document.getElementById('syncStatus');
dot.className = 'sync-dot' + (state === 'ok' ? '' : state === 'off' ? ' off' : ' syncing');
lbl.textContent = text || '';
}}
/* ─── localStorage Cache ─── */
function saveLocal() {{ try {{ localStorage.setItem(LS_KEY, JSON.stringify(todos)); }} catch(e) {{}} }}
function loadLocal() {{ try {{ var s = localStorage.getItem(LS_KEY); if (s) return JSON.parse(s); }} catch(e) {{}} return null; }}
/* ─── Supabase API ─── */
function supaGet(cb) {{
var xhr = new XMLHttpRequest();
xhr.open('GET', SUPA_URL + '/rest/v1/todo_sync?id=eq.1&select=data', true);
xhr.setRequestHeader('apikey', SUPA_KEY);
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
xhr.timeout = 10000;
xhr.onload = function() {{
if (xhr.status === 200) {{
try {{
var rows = JSON.parse(xhr.responseText);
if (rows && rows.length > 0 && rows[0].data) {{ cb(rows[0].data, true); return; }}
}} catch(e) {{}}
}}
cb(null, false);
}};
xhr.onerror = function() {{ cb(null, false); }};
xhr.ontimeout = function() {{ cb(null, false); }};
xhr.send();
}}
function supaPut(data, cb) {{
var xhr = new XMLHttpRequest();
xhr.open('PATCH', SUPA_URL + '/rest/v1/todo_sync?id=eq.1', true);
xhr.setRequestHeader('apikey', SUPA_KEY);
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.timeout = 10000;
xhr.onload = function() {{ if(cb) cb(xhr.status >= 200 && xhr.status < 300); }};
xhr.onerror = function() {{ if(cb) cb(false); }};
xhr.ontimeout = function() {{ if(cb) cb(false); }};
xhr.send(JSON.stringify({{data: data}}));
}}
/* ─── Sync ─── */
function syncNow() {{
setSyncState('syncing', 'Synchronisiere...');
supaGet(function(data, ok) {{
if (ok && data) {{
todos = data;
saveLocal();
setSyncState('ok', 'Cloud verbunden');
render();
}} else {{
var cached = loadLocal();
if (cached && cached.length > 0 && todos.length === 0) {{ todos = cached; }}
setSyncState('off', 'Offline (lokaler Cache)');
render();
}}
}});
}}
function pushToCloud() {{
saveLocal();
setSyncState('syncing', 'Speichere...');
supaPut(todos, function(ok) {{
setSyncState(ok ? 'ok' : 'off', ok ? 'Cloud verbunden' : 'Offline (lokal gespeichert)');
}});
}}
/* ─── Rendering ─── */
function render() {{
var list = document.getElementById('todoList');
var today = new Date().toISOString().split('T')[0];
var hasManualOrder = todos.some(function(t) {{ return typeof t.manualOrder === 'number'; }});
if (hasManualOrder) {{
todos.sort(function(a,b) {{
var oA = typeof a.manualOrder === 'number' ? a.manualOrder : 9999;
var oB = typeof b.manualOrder === 'number' ? b.manualOrder : 9999;
return oA - oB;
}});
}} else {{
todos.sort(function(a,b) {{
var dA = a.done?1:0, dB = b.done?1:0;
if (dA !== dB) return dA - dB;
var urgA = (!a.done && a.date && a.date < today) ? 0 : (!a.done && a.date) ? 1 : (!a.done) ? 2 : 3;
var urgB = (!b.done && b.date && b.date < today) ? 0 : (!b.done && b.date) ? 1 : (!b.done) ? 2 : 3;
if (urgA !== urgB) return urgA - urgB;
var pMap = {{1:0,2:1,0:2}};
var pA = pMap[a.priority||0]||2, pB = pMap[b.priority||0]||2;
if (pA !== pB) return pA - pB;
return (a.date||'9999') < (b.date||'9999') ? -1 : 1;
}});
}}
var done = 0, overdue = 0;
for (var k=0; k<todos.length; k++) {{
if (todos[k].done) done++;
if (!todos[k].done && todos[k].date && todos[k].date < today) overdue++;
}}
var cText = done + '/' + todos.length + ' erledigt';
if (overdue) cText += ' &middot; <span class="overdue-count">' + overdue + ' ueberfaellig</span>';
document.getElementById('counter').innerHTML = cText;
if (!todos.length) {{
list.innerHTML = '<div class="empty-state"><div class="icon">&#x2705;</div><p>Keine Aufgaben</p></div>';
return;
}}
var html = '';
for (var i=0; i<todos.length; i++) {{
var t = todos[i];
var isOverdue = !t.done && t.date && t.date < today;
var cls = '';
if (t.done) cls = 'done';
else if (isOverdue) cls = 'overdue';
else if (t.priority === 1) cls = 'prio1';
else if (t.priority === 2) cls = 'prio2';
var meta = '';
if (t.date) {{
var d = t.date.split('-');
var dtxt = d[2]+'.'+d[1]+'.'+d[0];
meta += '<span class="date-tag'+(isOverdue?' overdue':'')+'">'+(isOverdue?'! ':'')+dtxt+'</span>';
}}
if (t.priority===1) meta += '<span class="prio-tag p1">Hoch</span>';
if (t.priority===2) meta += '<span class="prio-tag p2">Mittel</span>';
if (t.notes && t.notes.trim()) meta += '<span class="notes-indicator">Notiz</span>';
if (t.sender) meta += '<span class="sender-tag">von '+esc(t.sender)+'</span>';
html += '<div class="todo-item '+cls+'" data-i="'+i+'">'
+ '<div class="drag-handle" data-drag="'+i+'">&#x2261;</div>'
+ '<div class="check" onclick="toggleDone('+i+')">'+(t.done?'&#x2713;':'')+'</div>'
+ '<div class="content" onclick="openDetail('+i+')">'
+ '<div class="task-text">'+esc(t.text)+'</div>'
+ (meta?'<div class="meta">'+meta+'</div>':'')
+ '</div>'
+ '<div class="delete-btn" onclick="deleteTodo('+i+')">&#x2715;</div>'
+ '</div>';
}}
list.innerHTML = html;
}}
function esc(s) {{ var d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; }}
/* ─── CRUD ─── */
function addTodo() {{
var inp = document.getElementById('newText');
var text = inp.value.trim();
if (!text) return;
var dateVal = document.getElementById('newDate').value || null;
var maxOrd = -1;
for (var mo=0; mo<todos.length; mo++) {{
if (typeof todos[mo].manualOrder === 'number' && todos[mo].manualOrder > maxOrd) maxOrd = todos[mo].manualOrder;
}}
var newOrd = maxOrd >= 0 ? maxOrd + 1 : undefined;
var newItem = {{ id: Date.now(), text: text, done: false, date: dateVal,
priority: newPrio, notes: '', created: new Date().toISOString() }};
if (typeof newOrd === 'number') newItem.manualOrder = newOrd;
todos.push(newItem);
inp.value = ''; document.getElementById('newDate').value = '';
newPrio = 0; updatePrioBtn(); render(); pushToCloud();
}}
function toggleDone(i) {{ todos[i].done = !todos[i].done; render(); pushToCloud(); }}
function deleteTodo(i) {{
if (!confirm('Loeschen: "'+todos[i].text.substring(0,40)+'"?')) return;
todos.splice(i, 1); render(); pushToCloud();
}}
function deleteDone() {{
if (!confirm('Alle erledigten Aufgaben loeschen?')) return;
todos = todos.filter(function(t) {{ return !t.done; }}); render(); pushToCloud();
}}
function resetSort() {{
for (var rs=0; rs<todos.length; rs++) {{ delete todos[rs].manualOrder; }}
render(); pushToCloud();
}}
function cyclePrio() {{ newPrio = newPrio === 0 ? 1 : newPrio === 1 ? 2 : 0; updatePrioBtn(); }}
function updatePrioBtn() {{
var b = document.getElementById('newPrioBtn');
b.className = 'prio-btn p' + newPrio;
b.textContent = newPrio===1?'Hoch':newPrio===2?'Mittel':'Keine';
}}
function openDetail(i) {{
editIdx = i; var t = todos[i];
document.getElementById('editText').value = t.text||'';
document.getElementById('editNotes').value = t.notes||'';
document.getElementById('editDate').value = t.date||'';
editPrio = t.priority||0; updateEditPrio();
document.getElementById('detailModal').classList.add('show');
}}
function closeDetail() {{ document.getElementById('detailModal').classList.remove('show'); editIdx = -1; }}
function setEditPrio(p) {{ editPrio = p; updateEditPrio(); }}
function updateEditPrio() {{
var btns = document.querySelectorAll('.prio-group button');
for (var j=0; j<btns.length; j++) {{
var bp = parseInt(btns[j].className.replace('p',''));
if (bp === editPrio) btns[j].classList.add('active');
else btns[j].classList.remove('active');
}}
}}
function saveDetail() {{
if (editIdx < 0) return;
var text = document.getElementById('editText').value.trim();
if (!text) {{ alert('Text darf nicht leer sein.'); return; }}
todos[editIdx].text = text;
todos[editIdx].notes = document.getElementById('editNotes').value;
todos[editIdx].date = document.getElementById('editDate').value || null;
todos[editIdx].priority = editPrio;
closeDetail(); render(); pushToCloud();
}}
document.getElementById('newText').addEventListener('keydown', function(e) {{
if (e.key === 'Enter') {{ e.preventDefault(); addTodo(); }}
}});
/* ─── Drag & Drop (Touch + Mouse) ─── */
var dragState = {{ active: false, fromIdx: -1, curIdx: -1, startY: 0, indicator: null }};
function initDragHandlers() {{
var handles = document.querySelectorAll('.drag-handle');
for (var h=0; h<handles.length; h++) {{
handles[h].addEventListener('touchstart', onDragStart, {{passive: false}});
handles[h].addEventListener('mousedown', onDragStartMouse);
}}
}}
function onDragStart(e) {{
e.preventDefault();
var idx = parseInt(e.currentTarget.getAttribute('data-drag'));
dragState.active = true;
dragState.fromIdx = idx;
dragState.curIdx = idx;
dragState.startY = e.touches[0].clientY;
var item = e.currentTarget.closest('.todo-item');
if (item) item.classList.add('dragging');
document.addEventListener('touchmove', onDragMove, {{passive: false}});
document.addEventListener('touchend', onDragEnd);
}}
function onDragStartMouse(e) {{
e.preventDefault();
var idx = parseInt(e.currentTarget.getAttribute('data-drag'));
dragState.active = true;
dragState.fromIdx = idx;
dragState.curIdx = idx;
dragState.startY = e.clientY;
var item = e.currentTarget.closest('.todo-item');
if (item) item.classList.add('dragging');
document.addEventListener('mousemove', onDragMoveMouse);
document.addEventListener('mouseup', onDragEndMouse);
}}
function getDropIndex(clientY) {{
var items = document.querySelectorAll('.todo-item');
for (var j=0; j<items.length; j++) {{
var rect = items[j].getBoundingClientRect();
var mid = rect.top + rect.height / 2;
if (clientY < mid) return j;
}}
return items.length;
}}
function showDropIndicator(targetIdx) {{
removeDropIndicator();
var items = document.querySelectorAll('.todo-item');
if (items.length === 0) return;
var ind = document.createElement('div');
ind.className = 'drop-indicator';
dragState.indicator = ind;
if (targetIdx >= items.length) {{
items[items.length-1].parentNode.appendChild(ind);
}} else {{
items[targetIdx].parentNode.insertBefore(ind, items[targetIdx]);
}}
}}
function removeDropIndicator() {{
if (dragState.indicator && dragState.indicator.parentNode) {{
dragState.indicator.parentNode.removeChild(dragState.indicator);
}}
dragState.indicator = null;
}}
function onDragMove(e) {{
if (!dragState.active) return;
e.preventDefault();
var clientY = e.touches[0].clientY;
var targetIdx = getDropIndex(clientY);
if (targetIdx !== dragState.curIdx) {{
dragState.curIdx = targetIdx;
showDropIndicator(targetIdx);
}}
}}
function onDragMoveMouse(e) {{
if (!dragState.active) return;
e.preventDefault();
var targetIdx = getDropIndex(e.clientY);
if (targetIdx !== dragState.curIdx) {{
dragState.curIdx = targetIdx;
showDropIndicator(targetIdx);
}}
}}
function finishDrag() {{
if (!dragState.active) return;
dragState.active = false;
removeDropIndicator();
var items = document.querySelectorAll('.todo-item.dragging');
for (var d=0; d<items.length; d++) items[d].classList.remove('dragging');
document.removeEventListener('touchmove', onDragMove);
document.removeEventListener('touchend', onDragEnd);
document.removeEventListener('mousemove', onDragMoveMouse);
document.removeEventListener('mouseup', onDragEndMouse);
var from = dragState.fromIdx;
var to = dragState.curIdx;
if (from < 0 || from >= todos.length) return;
if (to > from) to--;
if (from === to) return;
var moved = todos.splice(from, 1)[0];
todos.splice(to, 0, moved);
for (var mi=0; mi<todos.length; mi++) {{ todos[mi].manualOrder = mi; }}
render();
pushToCloud();
}}
function onDragEnd(e) {{ finishDrag(); }}
function onDragEndMouse(e) {{ finishDrag(); }}
/* Hook drag handlers after each render */
var origRender = render;
render = function() {{ origRender(); initDragHandlers(); }};
/* ─── Tab-Switch ─── */
function switchTab(tab) {{
var todoPage = document.getElementById('todosPage');
var notesPage = document.getElementById('notesPage');
var tabTodo = document.getElementById('tabTodo');
var tabNotes = document.getElementById('tabNotes');
if (tab === 'notes') {{
todoPage.classList.add('hidden');
notesPage.classList.add('active');
tabTodo.classList.remove('active');
tabNotes.classList.add('active');
renderNotes();
}} else {{
notesPage.classList.remove('active');
todoPage.classList.remove('hidden');
tabNotes.classList.remove('active');
tabTodo.classList.add('active');
}}
}}
/* ─── Notizen ─── */
var userNotes = [];
var editNoteIdx = -1;
var NOTES_LS_KEY = 'aza_notes_v1';
function saveNotesLocal() {{ try {{ localStorage.setItem(NOTES_LS_KEY, JSON.stringify(userNotes)); }} catch(e) {{}} }}
function loadNotesLocal() {{ try {{ var s = localStorage.getItem(NOTES_LS_KEY); if (s) return JSON.parse(s); }} catch(e) {{}} return null; }}
function supaGetNotes(cb) {{
var xhr = new XMLHttpRequest();
xhr.open('GET', SUPA_URL + '/rest/v1/todo_sync?id=eq.2&select=data', true);
xhr.setRequestHeader('apikey', SUPA_KEY);
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
xhr.timeout = 10000;
xhr.onload = function() {{
if (xhr.status === 200) {{
try {{
var rows = JSON.parse(xhr.responseText);
if (rows && rows.length > 0 && rows[0].data) {{ cb(rows[0].data, true); return; }}
}} catch(e) {{}}
}}
cb(null, false);
}};
xhr.onerror = function() {{ cb(null, false); }};
xhr.ontimeout = function() {{ cb(null, false); }};
xhr.send();
}}
function supaPutNotes(data, cb) {{
var xhr = new XMLHttpRequest();
xhr.open('PATCH', SUPA_URL + '/rest/v1/todo_sync?id=eq.2', true);
xhr.setRequestHeader('apikey', SUPA_KEY);
xhr.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.timeout = 10000;
xhr.onload = function() {{
if (xhr.status >= 200 && xhr.status < 300) {{ if(cb) cb(true); }}
else {{
var xhr2 = new XMLHttpRequest();
xhr2.open('POST', SUPA_URL + '/rest/v1/todo_sync', true);
xhr2.setRequestHeader('apikey', SUPA_KEY);
xhr2.setRequestHeader('Authorization', 'Bearer ' + SUPA_KEY);
xhr2.setRequestHeader('Content-Type', 'application/json');
xhr2.setRequestHeader('Prefer', 'return=minimal');
xhr2.timeout = 10000;
xhr2.onload = function() {{ if(cb) cb(xhr2.status >= 200 && xhr2.status < 300); }};
xhr2.onerror = function() {{ if(cb) cb(false); }};
xhr2.send(JSON.stringify({{id: 2, data: data}}));
}}
}};
xhr.onerror = function() {{ if(cb) cb(false); }};
xhr.ontimeout = function() {{ if(cb) cb(false); }};
xhr.send(JSON.stringify({{data: data}}));
}}
function syncNotesNow() {{
supaGetNotes(function(data, ok) {{
if (ok && data) {{
userNotes = data;
saveNotesLocal();
}} else {{
var cached = loadNotesLocal();
if (cached && cached.length > 0 && userNotes.length === 0) {{ userNotes = cached; }}
}}
renderNotes();
}});
}}
function pushNotesToCloud() {{
saveNotesLocal();
supaPutNotes(userNotes, function(ok) {{}});
}}
function renderNotes() {{
var list = document.getElementById('notesList');
if (!userNotes || !userNotes.length) {{
list.innerHTML = '<div class="empty-state"><div class="icon">&#x1F4DD;</div><p>Keine Notizen</p></div>';
return;
}}
var html = '';
for (var i=0; i<userNotes.length; i++) {{
var n = userNotes[i];
var preview = (n.text||'').substring(0, 120);
if ((n.text||'').length > 120) preview += '...';
var dateStr = '';
try {{
var d = new Date(n.created);
dateStr = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', {{hour:'2-digit',minute:'2-digit'}});
}} catch(e) {{}}
html += '<div class="note-item" onclick="openNoteDetail('+i+')">'
+ '<div class="note-title">'+esc(n.title||'Notiz')+'</div>'
+ (preview ? '<div class="note-preview">'+esc(preview)+'</div>' : '')
+ (dateStr ? '<div class="note-date">'+dateStr+'</div>' : '')
+ '<div class="delete-btn" onclick="event.stopPropagation(); deleteNote('+i+')">&#x2715;</div>'
+ '</div>';
}}
list.innerHTML = html;
}}
function addNote() {{
var titleInp = document.getElementById('noteTitle');
var textInp = document.getElementById('noteText');
var title = titleInp.value.trim() || 'Notiz';
var text = textInp.value.trim();
userNotes.push({{ id: Date.now(), title: title, text: text, created: new Date().toISOString() }});
titleInp.value = ''; textInp.value = '';
renderNotes(); pushNotesToCloud();
}}
function deleteNote(i) {{
if (!confirm('Notiz "'+((userNotes[i]||{{}}).title||'').substring(0,30)+'" loeschen?')) return;
userNotes.splice(i, 1); renderNotes(); pushNotesToCloud();
}}
function openNoteDetail(i) {{
editNoteIdx = i; var n = userNotes[i];
document.getElementById('editNoteTitle').value = n.title||'';
document.getElementById('editNoteText').value = n.text||'';
document.getElementById('noteDetailModal').classList.add('show');
}}
function closeNoteDetail() {{
document.getElementById('noteDetailModal').classList.remove('show');
editNoteIdx = -1;
}}
function saveNoteDetail() {{
if (editNoteIdx < 0) return;
var title = document.getElementById('editNoteTitle').value.trim();
if (!title) title = 'Notiz';
userNotes[editNoteIdx].title = title;
userNotes[editNoteIdx].text = document.getElementById('editNoteText').value;
closeNoteDetail(); renderNotes(); pushNotesToCloud();
}}
/* ─── Start ─── */
var cached = loadLocal();
if (cached && cached.length > 0) {{ todos = cached; render(); }}
var cachedNotes = loadNotesLocal();
if (cachedNotes && cachedNotes.length > 0) {{ userNotes = cachedNotes; }}
syncNow();
syncNotesNow();
setInterval(syncNow, SYNC_INTERVAL);
setInterval(syncNotesNow, SYNC_INTERVAL);
if ('serviceWorker' in navigator) {{ navigator.serviceWorker.register('/sw.js').catch(function(){{}}); }}
</script>
</body>
</html>
'''
# ─── Server starten ───
def start_server(port=PORT, open_browser=True):
check_tls_or_exit()
_add_firewall_rule()
todos = _load_todos()
if todos:
print(f" Pushe {len(todos)} Todos nach Supabase...")
if _supabase_push(todos):
print(" OK!")
else:
print(" Warnung: Push fehlgeschlagen (Todos sind lokal gesichert)")
local_ip = _get_local_ip()
scheme = "https" if has_tls_config() else "http"
url_local = f"{scheme}://{local_ip}:{port}"
print(f"\n{'='*55}")
print(f" AzA To-Do Server (Supabase Cloud-Sync)")
print(f" Lokal: {scheme}://localhost:{port}")
print(f" Netzwerk: {url_local}")
print(f" TLS: {'AKTIV' if has_tls_config() else 'DEAKTIVIERT'}")
print(f"{'='*55}")
print(f"\n EINRICHTUNG iPhone:")
print(f" 1. Safari: {url_local}")
print(f" 2. Teilen -> 'Zum Home-Bildschirm'")
print(f" 3. Fertig! App funktioniert ab jetzt OHNE PC.")
print(f"\n Server laeuft. Ctrl+C zum Beenden.\n")
if open_browser:
threading.Timer(1.0, lambda: webbrowser.open(f"{scheme}://localhost:{port}")).start()
server = TodoServer(("0.0.0.0", port), TodoHandler)
if has_tls_config():
server.socket = create_ssl_context().wrap_socket(server.socket, server_side=True)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n Server beendet.")
server.server_close()
if __name__ == "__main__":
start_server()