diff --git a/app.py b/app.py
index a157941..ab91756 100644
--- a/app.py
+++ b/app.py
@@ -1,7 +1,10 @@
"""Einstiegspunkt der Flask-App. Registriert alle Blueprints."""
+import json
+import re
from datetime import date, datetime
from flask import Flask
+from markupsafe import Markup, escape
from config import Config
from db import init_db
@@ -21,14 +24,63 @@ def create_app(config_class=Config):
app.register_blueprint(admin_bp)
# Template/Filter-Helfer in Kontext verfügbar machen
+ from routes.journal_routes import ref_href, ref_type_label, bullet_label
+
@app.context_processor
def inject_globals():
today = date.today()
return {
"now": datetime.now(),
"today_str": today.strftime("%Y-%m-%d"),
+ "today_month": today.strftime("%Y-%m"),
+ "ref_href": ref_href,
+ "ref_type_label": ref_type_label,
+ "bullet_label": bullet_label,
}
+ @app.template_filter("linkify")
+ def linkify_filter(text):
+ """Macht URLs und E-Mail-Adressen im Notiztext klickbar."""
+ if not text:
+ return ""
+ safe = str(escape(text))
+ safe = re.sub(
+ r'(https?://[^\s<>]+)',
+ r'\1',
+ safe,
+ )
+ safe = re.sub(
+ r'([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})',
+ r'\1',
+ safe,
+ )
+ safe = re.sub(
+ r'(\+?[0-9][0-9\s\-\/]{6,})',
+ r'\1',
+ safe,
+ )
+ return Markup(safe)
+
+ @app.template_filter("refs")
+ def refs_filter(raw):
+ """Parst die refs-Spalte (JSON) zu einer Liste von dicts."""
+ if not raw:
+ return []
+ if isinstance(raw, (list, dict)):
+ return raw if isinstance(raw, list) else [raw]
+ try:
+ data = json.loads(raw)
+ return data if isinstance(data, list) else []
+ except (ValueError, TypeError):
+ return []
+
+ @app.template_filter("refvalue")
+ def ref_value_filter(ref):
+ """Liefert den anzuzeigenden Wert eines Verweises."""
+ if isinstance(ref, dict):
+ return ref.get("value", "")
+ return str(ref)
+
# Schema anlegen + Bootstrap-Admin beim ersten Start
with app.app_context():
from db import get_connection
diff --git a/db.py b/db.py
index 4104dbd..86dd03e 100644
--- a/db.py
+++ b/db.py
@@ -44,6 +44,8 @@ CREATE TABLE IF NOT EXISTS notes (
status ENUM('open', 'done', 'moved') NOT NULL DEFAULT 'open',
moved_to_date DATE NULL,
moved_to_future TINYINT(1) NOT NULL DEFAULT 0,
+ moved_from_date DATE NULL,
+ refs JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_notes_user FOREIGN KEY (user_id)
@@ -55,6 +57,7 @@ CREATE TABLE IF NOT EXISTS future_log (
user_id INT NOT NULL,
month_date DATE NOT NULL,
note_text TEXT NOT NULL,
+ refs JSON NULL,
status ENUM('open', 'done') NOT NULL DEFAULT 'open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_future_user FOREIGN KEY (user_id)
@@ -63,6 +66,26 @@ CREATE TABLE IF NOT EXISTS future_log (
"""
+# Spalten, die für bereits bestehende Datenbanken nachgezogen werden müssen
+MIGRATIONS = [
+ ("notes", "moved_from_date", "DATE NULL"),
+ ("notes", "refs", "JSON NULL"),
+ ("future_log", "refs", "JSON NULL"),
+]
+
+
+def _ensure_column(conn, table: str, column: str, definition: str) -> None:
+ """Fügt eine Spalte hinzu, falls sie noch nicht existiert (Migration)."""
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT COUNT(*) AS c FROM information_schema.COLUMNS "
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s",
+ (table, column),
+ )
+ if cur.fetchone()["c"] == 0:
+ cur.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
+
+
def init_db():
"""Erstellt das Schema, falls es noch nicht existiert."""
conn = get_connection()
@@ -72,5 +95,8 @@ def init_db():
stmt = statement.strip()
if stmt:
cur.execute(stmt)
+ # Migrationen für bestehende Datenbanken
+ for table, column, definition in MIGRATIONS:
+ _ensure_column(conn, table, column, definition)
finally:
conn.close()
diff --git a/routes/journal_routes.py b/routes/journal_routes.py
index c47a3ed..9a35879 100644
--- a/routes/journal_routes.py
+++ b/routes/journal_routes.py
@@ -1,11 +1,18 @@
-"""Journal-Routen: Index/Dashboard, Future Log, Wochenübersicht, Tagesansicht.
+"""Journal-Routen: Index/Dashboard, Future Log, Wochen-/Monatsübersicht, Tagesansicht.
-Enthält auch Helfer für Wochenberechnung und Notiz-Aktionen
-(erledigt markieren, in anderen Tag / Future Log verschieben).
+Enthält auch Helfer für Wochen-/Monatsberechnung, Export (CSV) und
+Notiz-Aktionen (erledigt markieren, in anderen Tag / Future Log verschieben).
"""
+import csv
+import io
+import json
+import calendar
from datetime import date, datetime, timedelta
-from flask import Blueprint, render_template, request, redirect, url_for, session, flash
+from flask import (
+ Blueprint, render_template, request, redirect, url_for,
+ session, flash, Response,
+)
from auth import login_required
from db import get_connection
@@ -61,6 +68,49 @@ def _active_notes(user_id: int, note_date: date) -> list[dict]:
return [n for n in _fetch_notes(user_id, note_date) if n["status"] != "moved"]
+def parse_refs(raw) -> list[dict]:
+ """Parst den JSON-Spaltenwert der Verweise in eine Liste."""
+ if not raw:
+ return []
+ if isinstance(raw, (list, dict)):
+ return raw if isinstance(raw, list) else [raw]
+ try:
+ data = json.loads(raw)
+ return data if isinstance(data, list) else []
+ except (ValueError, TypeError):
+ return []
+
+
+REF_TYPE_DISPLAY = {
+ "email": "📧",
+ "link": "🔗",
+ "phone": "📞",
+ "mention": "@",
+}
+
+
+def ref_type_label(ref_type: str) -> str:
+ return REF_TYPE_DISPLAY.get(ref_type, "🔖")
+
+
+def ref_href(ref_type: str, value: str) -> str:
+ value = (value or "").strip()
+ if ref_type == "email":
+ return f"mailto:{value}"
+ if ref_type == "phone":
+ return f"tel:{value.replace(' ', '')}"
+ if ref_type == "mention":
+ return f"mailto:{value}"
+ # link und Standard
+ if value.startswith(("http://", "https://")):
+ return value
+ return f"https://{value}"
+
+
+def bullet_label(bullet_type: str) -> str:
+ return BULLET_LABELS.get(bullet_type, "Notiz")
+
+
@journal_bp.route("/")
@login_required
def index():
@@ -143,7 +193,7 @@ def future():
@journal_bp.route("/woche")
@login_required
def week():
- """Wochenübersicht: Mon–So mit Status je Tag + offene Aufgaben."""
+ """Wochenübersicht: Mon–So mit allen Einzeleinträgen pro Tag."""
week_param = request.args.get("week", "")
if week_param:
try:
@@ -155,46 +205,210 @@ def week():
monday = monday_of_week(date.today())
week_days = get_week_days(monday)
+ sunday = monday + timedelta(days=6)
+
+ # Alle Notizen der Woche holen (inkl. erledigter) und nach Tag gruppieren
+ with get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT * FROM notes WHERE user_id = %s "
+ "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC",
+ (_user_id(), monday, sunday),
+ )
+ rows = cur.fetchall()
+
+ by_date: dict[date, list[dict]] = {d: [] for d in week_days}
+ for n in rows:
+ by_date.setdefault(n["note_date"], []).append(n)
days_detail = []
for d in week_days:
- notes = _active_notes(_user_id(), d)
+ notes = by_date.get(d, [])
+ active = [n for n in notes if n["status"] != "moved"]
days_detail.append(
{
"date": d,
"weekday": WEEKDAYS[d.weekday()],
- "open": sum(1 for n in notes if n["status"] == "open"),
- "done": sum(1 for n in notes if n["status"] == "done"),
+ "open": sum(1 for n in active if n["status"] == "open"),
+ "done": sum(1 for n in active if n["status"] == "done"),
"moved": sum(1 for n in notes if n["status"] == "moved"),
- "total": len(notes),
+ "total": len(active),
+ "notes": active,
}
)
- # Offene Aufgaben der ausgewählten Woche
- sunday = monday + timedelta(days=6)
- with get_connection() as conn:
- with conn.cursor() as cur:
- cur.execute(
- "SELECT * FROM notes WHERE user_id = %s AND status = 'open' "
- "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC",
- (_user_id(), monday, sunday),
- )
- open_notes = cur.fetchall()
-
prev_monday = monday - timedelta(days=7)
next_monday = monday + timedelta(days=7)
return render_template(
"week.html",
monday=monday,
+ sunday=sunday,
days_detail=days_detail,
- open_notes=open_notes,
weekdays=WEEKDAYS,
prev_monday=prev_monday,
next_monday=next_monday,
+ bullet_labels=BULLET_LABELS,
)
+@journal_bp.route("/monat")
+@login_required
+def month():
+ """Monatsübersicht: Kalenderraster + Liste aller Einträge des Monats."""
+ month_param = request.args.get("month", "")
+ if month_param:
+ try:
+ year, mon = (int(x) for x in month_param.split("-"))
+ first = date(year, mon, 1)
+ except (ValueError, TypeError):
+ first = date.today().replace(day=1)
+ else:
+ first = date.today().replace(day=1)
+
+ last = date(first.year, first.month, calendar.monthrange(first.year, first.month)[1])
+
+ # Alle Notizen des Monats holen
+ with get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT * FROM notes WHERE user_id = %s "
+ "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC",
+ (_user_id(), first, last),
+ )
+ rows = cur.fetchall()
+
+ by_date: dict[int, list[dict]] = {}
+ for n in rows:
+ if n["status"] != "moved":
+ by_date.setdefault(n["note_date"].day, []).append(n)
+
+ # Kalenderraster aufbauen (Wochen Montag–Sonntag)
+ start_pad = first.weekday() # 0=Montag
+ start = first - timedelta(days=start_pad)
+ weeks: list[list[dict]] = []
+ current = start
+ for _ in range(6):
+ week_cells = []
+ for i in range(7):
+ d = current + timedelta(days=i)
+ in_month = d.month == first.month
+ week_cells.append(
+ {
+ "date": d,
+ "in_month": in_month,
+ "today": d == date.today(),
+ "notes": by_date.get(d.day, []) if in_month else [],
+ }
+ )
+ weeks.append(week_cells)
+ current += timedelta(days=7)
+ if current > last + timedelta(days=6):
+ break
+
+ now_first = date.today().replace(day=1)
+ prev_month = (first.replace(day=1) - timedelta(days=1)).replace(day=1)
+ next_month = (first.replace(day=28) + timedelta(days=7)).replace(day=1)
+
+ return render_template(
+ "monat.html",
+ first=first,
+ last=last,
+ weeks=weeks,
+ weekdays=WEEKDAYS,
+ prev_month=prev_month,
+ next_month=next_month,
+ all_notes=rows,
+ )
+
+
+def _csv_response(title: str, filename: str, rows: list[dict]) -> Response:
+ """Baut eine CSV-Datei als HTTP-Antwort zusammen."""
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow([
+ "Datum", "Wochentag", "Bullet", "Typ", "Notiz", "Priorität",
+ "Status", "Verweise",
+ ])
+ for n in rows:
+ refs = ", ".join(
+ f"{ref_type_label(r.get('type', ''))} {r.get('value', '')}"
+ for r in parse_refs(n.get("refs"))
+ )
+ writer.writerow([
+ n["note_date"].strftime("%d.%m.%Y"),
+ WEEKDAYS[n["note_date"].weekday()],
+ n.get("bullet_type", ""),
+ bullet_label(n.get("bullet_type", "-")),
+ n.get("note_text", ""),
+ n.get("priority", 0),
+ {"open": "Offen", "done": "Erledigt", "moved": "Verschoben"}.get(
+ n.get("status"), n.get("status", "")
+ ),
+ refs,
+ ])
+ output.seek(0)
+ return Response(
+ "\ufeff" + output.getvalue(), # BOM für Excel
+ mimetype="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f"attachment; filename={filename}.csv"},
+ )
+
+
+@journal_bp.route("/woche/export")
+@login_required
+def export_week():
+ week_param = request.args.get("week", "")
+ if week_param:
+ try:
+ iso_year, iso_week = week_param.split("-W")
+ monday = date.fromisocalendar(int(iso_year), int(iso_week), 1)
+ except (ValueError, TypeError):
+ monday = monday_of_week(date.today())
+ else:
+ monday = monday_of_week(date.today())
+ sunday = monday + timedelta(days=6)
+
+ with get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT * FROM notes WHERE user_id = %s "
+ "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC",
+ (_user_id(), monday, sunday),
+ )
+ rows = cur.fetchall()
+
+ filename = f"woche_{monday.strftime('%Y-%m-%d')}"
+ return _csv_response("Wochenübersicht", filename, rows)
+
+
+@journal_bp.route("/monat/export")
+@login_required
+def export_month():
+ month_param = request.args.get("month", "")
+ if month_param:
+ try:
+ year, mon = (int(x) for x in month_param.split("-"))
+ first = date(year, mon, 1)
+ except (ValueError, TypeError):
+ first = date.today().replace(day=1)
+ else:
+ first = date.today().replace(day=1)
+ last = date(first.year, first.month, calendar.monthrange(first.year, first.month)[1])
+
+ with get_connection() as conn:
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT * FROM notes WHERE user_id = %s "
+ "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC",
+ (_user_id(), first, last),
+ )
+ rows = cur.fetchall()
+
+ filename = f"monat_{first.strftime('%Y-%m')}"
+ return _csv_response("Monatsübersicht", filename, rows)
+
+
@journal_bp.route("/tag/
| Tag | -Datum | -Offen | -Erledigt | -Gesamt | -- |
|---|---|---|---|---|---|
| {{ day.weekday }} | -{{ day.date.strftime('%d.%m.%Y') }} | -{{ day.open }} | -{{ day.done }} | -{{ day.total }} | -Öffnen | -
Keine offenen Aufgaben in dieser Woche. 🎉
+Keine Einträge.
{% endif %}