885 lines
30 KiB
Python
885 lines
30 KiB
Python
"""Journal-Routen: Index/Dashboard, Future Log, Wochen-/Monatsübersicht, Tagesansicht.
|
||
|
||
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 urllib.parse import urlencode
|
||
|
||
from flask import (
|
||
Blueprint, render_template, request, redirect, url_for,
|
||
session, flash, Response,
|
||
)
|
||
|
||
from auth import login_required
|
||
from db import get_connection
|
||
|
||
journal_bp = Blueprint("journal", __name__)
|
||
|
||
# Deutsche Wochentage / Monate
|
||
WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||
MONTHS = [
|
||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||
]
|
||
BULLET_LABELS = {
|
||
".": "Aufgabe",
|
||
"x": "Termin",
|
||
"-": "Notiz",
|
||
"?": "Frage",
|
||
}
|
||
|
||
|
||
def _user_id() -> int:
|
||
return session["user_id"]
|
||
|
||
|
||
def monday_of_week(d: date) -> date:
|
||
"""Liefert den Montag der Woche, in der d liegt."""
|
||
return d - timedelta(days=d.weekday())
|
||
|
||
|
||
def get_week_days(monday: date) -> list[date]:
|
||
return [monday + timedelta(days=i) for i in range(7)]
|
||
|
||
|
||
def parse_date(s: str) -> date | None:
|
||
try:
|
||
return datetime.strptime(s, "%Y-%m-%d").date()
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _fetch_notes(user_id: int, note_date: date) -> list[dict]:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT * FROM notes WHERE user_id = %s AND note_date = %s "
|
||
"ORDER BY id ASC",
|
||
(user_id, note_date),
|
||
)
|
||
return cur.fetchall()
|
||
|
||
|
||
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")
|
||
|
||
|
||
PRIORITY_LABELS = {0: "Niedrig", 1: "Mittel", 2: "Hoch"}
|
||
STATUS_LABELS = {"open": "Offen", "done": "Erledigt", "moved": "Verschoben"}
|
||
|
||
|
||
def _report_filters() -> dict:
|
||
"""Liest die Report-Filter aus request.args (GET-Formular/Export-Links)."""
|
||
args = request.args
|
||
range_type = args.get("range", "week")
|
||
|
||
if range_type == "month":
|
||
month_param = args.get("month", "")
|
||
try:
|
||
y, mo = (int(x) for x in month_param.split("-"))
|
||
start = date(y, mo, 1)
|
||
except (ValueError, TypeError):
|
||
start = date.today().replace(day=1)
|
||
end = date(start.year, start.month,
|
||
calendar.monthrange(start.year, start.month)[1])
|
||
label = start.strftime("%B %Y")
|
||
elif range_type == "custom":
|
||
start = parse_date(args.get("from", ""))
|
||
end = parse_date(args.get("to", ""))
|
||
if not start or not end or end < start:
|
||
start = monday_of_week(date.today())
|
||
end = start + timedelta(days=6)
|
||
label = f"{start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}"
|
||
range_type = "custom"
|
||
else: # week
|
||
week_param = args.get("week", "")
|
||
try:
|
||
iso_year, iso_week = week_param.split("-W")
|
||
start = date.fromisocalendar(int(iso_year), int(iso_week), 1)
|
||
except (ValueError, TypeError):
|
||
start = monday_of_week(date.today())
|
||
end = start + timedelta(days=6)
|
||
label = f"Woche {start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')}"
|
||
|
||
# Filter (leere Auswahl = alle zulassen)
|
||
weekdays = [int(x) for x in args.getlist("wd")]
|
||
bullets = [b for b in args.getlist("bullet") if b in BULLET_LABELS]
|
||
statuses = [s for s in args.getlist("status") if s in STATUS_LABELS]
|
||
priorities = [int(p) for p in args.getlist("priority") if p.isdigit()]
|
||
|
||
if not weekdays:
|
||
weekdays = list(range(7))
|
||
if not bullets:
|
||
bullets = list(BULLET_LABELS.keys())
|
||
if not statuses:
|
||
statuses = ["open", "done"]
|
||
if not priorities:
|
||
priorities = [0, 1, 2]
|
||
|
||
return {
|
||
"range_type": range_type,
|
||
"start": start,
|
||
"end": end,
|
||
"label": label,
|
||
"weekdays": weekdays,
|
||
"bullets": bullets,
|
||
"statuses": statuses,
|
||
"priorities": priorities,
|
||
}
|
||
|
||
|
||
def _run_report(uid: int, f: dict) -> tuple[list[dict], dict]:
|
||
"""Führt den Report aus und liefert (rows, summary)."""
|
||
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",
|
||
(uid, f["start"], f["end"]),
|
||
)
|
||
rows = cur.fetchall()
|
||
|
||
filtered = []
|
||
for n in rows:
|
||
if n["note_date"].weekday() not in f["weekdays"]:
|
||
continue
|
||
if n["bullet_type"] not in f["bullets"]:
|
||
continue
|
||
if n["status"] not in f["statuses"]:
|
||
continue
|
||
if n["priority"] not in f["priorities"]:
|
||
continue
|
||
filtered.append(n)
|
||
|
||
rows_out = []
|
||
for n in filtered:
|
||
refs = ", ".join(
|
||
f"{ref_type_label(r.get('type', ''))} {r.get('value', '')}"
|
||
for r in parse_refs(n.get("refs"))
|
||
)
|
||
rows_out.append({
|
||
"id": n["id"],
|
||
"date_str": n["note_date"].strftime("%d.%m.%Y"),
|
||
"iso": n["note_date"].strftime("%Y-%m-%d"),
|
||
"weekday": WEEKDAYS[n["note_date"].weekday()],
|
||
"bullet": n["bullet_type"],
|
||
"bullet_label": bullet_label(n["bullet_type"]),
|
||
"note_text": n["note_text"],
|
||
"priority": PRIORITY_LABELS.get(n.get("priority", 0), str(n.get("priority", ""))),
|
||
"status": STATUS_LABELS.get(n.get("status"), n.get("status", "")),
|
||
"status_code": n.get("status"),
|
||
"refs": refs,
|
||
})
|
||
|
||
summary = {
|
||
"total": len(rows_out),
|
||
"open": sum(1 for r in rows_out if r["status_code"] == "open"),
|
||
"done": sum(1 for r in rows_out if r["status_code"] == "done"),
|
||
}
|
||
return rows_out, summary
|
||
|
||
|
||
@journal_bp.route("/")
|
||
@login_required
|
||
def index():
|
||
"""Dashboard: aktuelle Woche + offene Aufgaben."""
|
||
today = date.today()
|
||
monday = monday_of_week(today)
|
||
week_days = get_week_days(monday)
|
||
|
||
# Detaillierte Info pro Tag
|
||
day_summary = []
|
||
for d in week_days:
|
||
notes = _active_notes(_user_id(), d)
|
||
day_summary.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"),
|
||
"total": len(notes),
|
||
}
|
||
)
|
||
|
||
# Offene Aufgaben außerhalb dieser Woche
|
||
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 < %s ORDER BY note_date ASC LIMIT 50",
|
||
(_user_id(), monday),
|
||
)
|
||
overdue = cur.fetchall()
|
||
|
||
return render_template(
|
||
"index.html",
|
||
today=today,
|
||
monday=monday,
|
||
day_summary=day_summary,
|
||
overdue=overdue,
|
||
weekdays=WEEKDAYS,
|
||
)
|
||
|
||
|
||
@journal_bp.route("/future", methods=["GET", "POST"])
|
||
@login_required
|
||
def future():
|
||
if request.method == "POST":
|
||
note_text = request.form.get("note_text", "").strip()
|
||
month_str = request.form.get("month", "")
|
||
month = parse_date(month_str)
|
||
if note_text and month:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"INSERT INTO future_log (user_id, month_date, note_text) "
|
||
"VALUES (%s, %s, %s)",
|
||
(_user_id(), month, note_text),
|
||
)
|
||
flash("Eintrag ins Future Log hinzugefügt.", "success")
|
||
else:
|
||
flash("Bitte Text und Monat angeben.", "danger")
|
||
return redirect(url_for("journal.future"))
|
||
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT * FROM future_log WHERE user_id = %s ORDER BY month_date ASC, id ASC",
|
||
(_user_id(),),
|
||
)
|
||
entries = cur.fetchall()
|
||
|
||
# Gruppieren nach Monat
|
||
grouped = {}
|
||
for e in entries:
|
||
key = e["month_date"].strftime("%Y-%m")
|
||
grouped.setdefault(key, []).append(e)
|
||
|
||
return render_template("future.html", grouped=grouped, months=MONTHS)
|
||
|
||
|
||
@journal_bp.route("/woche")
|
||
@login_required
|
||
def week():
|
||
"""Wochenübersicht: Mon–So mit allen Einzeleinträgen pro Tag."""
|
||
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())
|
||
|
||
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 = 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 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(active),
|
||
"notes": active,
|
||
}
|
||
)
|
||
|
||
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,
|
||
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.
|
||
|
||
Trennzeichen: Semikolon (deutsches Excel erwartet ';' statt Komma).
|
||
"""
|
||
output = io.StringIO()
|
||
writer = csv.writer(output, delimiter=";")
|
||
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("/report")
|
||
@login_required
|
||
def report():
|
||
"""Report-Generator: dynamischer Wochen-/Monatsbericht mit Filtern."""
|
||
filters = _report_filters()
|
||
rows, summary = _run_report(_user_id(), filters)
|
||
today = date.today()
|
||
|
||
# Für Export-Links: aktuelle Filter als Query-String aufbauen
|
||
# (urlencode erhält wiederholte Parameter wie wd=0&wd=1 – dict() würde sie kollabieren)
|
||
def _qs(fmt):
|
||
params = [("range", filters["range_type"]), ("format", fmt)]
|
||
params += [("wd", str(wd)) for wd in filters["weekdays"]]
|
||
params += [("bullet", b) for b in filters["bullets"]]
|
||
params += [("status", s) for s in filters["statuses"]]
|
||
params += [("priority", str(p)) for p in filters["priorities"]]
|
||
if filters["range_type"] == "month":
|
||
params.append(("month", filters["start"].strftime("%Y-%m")))
|
||
elif filters["range_type"] == "custom":
|
||
params.append(("from", filters["start"].strftime("%Y-%m-%d")))
|
||
params.append(("to", filters["end"].strftime("%Y-%m-%d")))
|
||
else:
|
||
params.append(("week", filters["start"].strftime("%G-W%V")))
|
||
return url_for("journal.report_export") + "?" + urlencode(params)
|
||
|
||
export_csv_url = _qs("csv")
|
||
export_pdf_url = _qs("pdf")
|
||
|
||
current_week = monday_of_week(today).strftime("%G-W%V")
|
||
current_month = today.strftime("%Y-%m")
|
||
|
||
filters["week_value"] = current_week
|
||
filters["from_value"] = filters["start"].strftime("%Y-%m-%d") if filters["range_type"] == "custom" else ""
|
||
filters["to_value"] = filters["end"].strftime("%Y-%m-%d") if filters["range_type"] == "custom" else ""
|
||
|
||
return render_template(
|
||
"report.html",
|
||
f=filters,
|
||
rows=rows,
|
||
summary=summary,
|
||
weekdays=WEEKDAYS,
|
||
bullet_labels=BULLET_LABELS,
|
||
current_week=current_week,
|
||
current_month=current_month,
|
||
today_str=today.strftime("%Y-%m-%d"),
|
||
export_csv_url=export_csv_url,
|
||
export_pdf_url=export_pdf_url,
|
||
)
|
||
|
||
|
||
@journal_bp.route("/report/export")
|
||
@login_required
|
||
def report_export():
|
||
"""Export des Report-Generators als CSV oder PDF (format=pdf|csv)."""
|
||
filters = _report_filters()
|
||
rows, summary = _run_report(_user_id(), filters)
|
||
fmt = request.args.get("format", "csv")
|
||
|
||
safe_name = filters["label"].replace(" ", "_").replace("–", "-")
|
||
filename = f"report_{safe_name}"
|
||
|
||
if fmt == "pdf":
|
||
from pdf_export import build_pdf
|
||
pdf_bytes = build_pdf(
|
||
title="Bullet-Journal-Report",
|
||
subtitle=filters["label"],
|
||
rows=rows,
|
||
summary=summary,
|
||
)
|
||
return Response(
|
||
pdf_bytes,
|
||
mimetype="application/pdf",
|
||
headers={
|
||
"Content-Disposition": f"attachment; filename={filename}.pdf"
|
||
},
|
||
)
|
||
|
||
# CSV – Semikolon als Trennzeichen (deutsches Excel erwartet ';')
|
||
output = io.StringIO()
|
||
writer = csv.writer(output, delimiter=";")
|
||
writer.writerow([
|
||
"Datum", "Wochentag", "Bullet", "Typ", "Notiz", "Priorität",
|
||
"Status", "Verweise",
|
||
])
|
||
for r in rows:
|
||
writer.writerow([
|
||
r["date_str"], r["weekday"], r["bullet"], r["bullet_label"],
|
||
r["note_text"], r["priority"], r["status"], r["refs"],
|
||
])
|
||
output.seek(0)
|
||
return Response(
|
||
"\ufeff" + output.getvalue(),
|
||
mimetype="text/csv; charset=utf-8",
|
||
headers={"Content-Disposition": f"attachment; filename={filename}.csv"},
|
||
)
|
||
|
||
|
||
@journal_bp.route("/note/<int:note_id>/edit", methods=["GET", "POST"])
|
||
@login_required
|
||
def edit_note(note_id: int):
|
||
"""Bearbeitet eine Notiz (Text, Bullet, Priorität, Verweise)."""
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT * FROM notes WHERE id = %s AND user_id = %s",
|
||
(note_id, _user_id()),
|
||
)
|
||
note = cur.fetchone()
|
||
|
||
if not note:
|
||
flash("Notiz nicht gefunden.", "danger")
|
||
return redirect(url_for("journal.index"))
|
||
|
||
if request.method == "POST":
|
||
bullet = request.form.get("bullet", "-")
|
||
note_text = request.form.get("note_text", "").strip()
|
||
try:
|
||
priority = int(request.form.get("priority", "0"))
|
||
except ValueError:
|
||
priority = 0
|
||
ref_type = request.form.get("ref_type", "").strip()
|
||
ref_value = request.form.get("ref_value", "").strip()
|
||
refs = None
|
||
if ref_value:
|
||
refs = json.dumps([{"type": ref_type or "link", "value": ref_value}])
|
||
|
||
if bullet not in (".", "x", "-", "?"):
|
||
bullet = "-"
|
||
if not note_text:
|
||
flash("Notiz darf nicht leer sein.", "danger")
|
||
else:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"UPDATE notes SET bullet_type = %s, note_text = %s, "
|
||
"priority = %s, refs = %s WHERE id = %s AND user_id = %s",
|
||
(bullet, note_text, priority, refs, note_id, _user_id()),
|
||
)
|
||
flash("Notiz aktualisiert.", "success")
|
||
return redirect(url_for("journal.day", date_str=note["note_date"].strftime("%Y-%m-%d")))
|
||
|
||
refs = parse_refs(note.get("refs"))
|
||
current_ref = refs[0] if refs else {}
|
||
|
||
return render_template(
|
||
"edit_note.html",
|
||
note=note,
|
||
bullet_labels=BULLET_LABELS,
|
||
current_ref=current_ref,
|
||
)
|
||
|
||
|
||
@journal_bp.route("/note/<int:note_id>/delete", methods=["POST"])
|
||
@login_required
|
||
def delete_note(note_id: int):
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT note_date FROM notes WHERE id = %s AND user_id = %s",
|
||
(note_id, _user_id()),
|
||
)
|
||
note = cur.fetchone()
|
||
if not note:
|
||
flash("Notiz nicht gefunden.", "danger")
|
||
return redirect(url_for("journal.index"))
|
||
cur.execute(
|
||
"DELETE FROM notes WHERE id = %s AND user_id = %s",
|
||
(note_id, _user_id()),
|
||
)
|
||
flash("Notiz gelöscht.", "success")
|
||
return redirect(request.referrer or url_for("journal.index"))
|
||
|
||
|
||
@journal_bp.route("/future/<int:entry_id>/edit", methods=["GET", "POST"])
|
||
@login_required
|
||
def edit_future(entry_id: int):
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT * FROM future_log WHERE id = %s AND user_id = %s",
|
||
(entry_id, _user_id()),
|
||
)
|
||
entry = cur.fetchone()
|
||
|
||
if not entry:
|
||
flash("Eintrag nicht gefunden.", "danger")
|
||
return redirect(url_for("journal.future"))
|
||
|
||
if request.method == "POST":
|
||
note_text = request.form.get("note_text", "").strip()
|
||
month_str = request.form.get("month", "")
|
||
month = parse_date(month_str)
|
||
if note_text and month:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"UPDATE future_log SET note_text = %s, month_date = %s "
|
||
"WHERE id = %s AND user_id = %s",
|
||
(note_text, month, entry_id, _user_id()),
|
||
)
|
||
flash("Eintrag aktualisiert.", "success")
|
||
else:
|
||
flash("Bitte Text und Monat angeben.", "danger")
|
||
return redirect(url_for("journal.future"))
|
||
|
||
return render_template("edit_future.html", entry=entry)
|
||
|
||
|
||
@journal_bp.route("/future/<int:entry_id>/delete", methods=["POST"])
|
||
@login_required
|
||
def delete_future(entry_id: int):
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"DELETE FROM future_log WHERE id = %s AND user_id = %s",
|
||
(entry_id, _user_id()),
|
||
)
|
||
flash("Eintrag aus dem Future Log gelöscht.", "success")
|
||
return redirect(url_for("journal.future"))
|
||
|
||
|
||
@journal_bp.route("/tag/<date_str>", methods=["GET", "POST"])
|
||
@login_required
|
||
def day(date_str: str):
|
||
"""Tagesansicht mit 3 Spalten (Bullet | Notiz | Priorität)."""
|
||
target = parse_date(date_str)
|
||
if target is None:
|
||
flash("Ungültiges Datum.", "danger")
|
||
return redirect(url_for("journal.index"))
|
||
|
||
if request.method == "POST":
|
||
bullet = request.form.get("bullet", "-")
|
||
note_text = request.form.get("note_text", "").strip()
|
||
priority = request.form.get("priority", "0")
|
||
try:
|
||
priority = int(priority)
|
||
except ValueError:
|
||
priority = 0
|
||
|
||
# Verweise (optional): Typ + Wert
|
||
ref_type = request.form.get("ref_type", "").strip()
|
||
ref_value = request.form.get("ref_value", "").strip()
|
||
refs = None
|
||
if ref_value:
|
||
refs = json.dumps([{"type": ref_type or "link", "value": ref_value}])
|
||
|
||
if bullet not in (".", "x", "-", "?"):
|
||
bullet = "-"
|
||
if note_text:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"INSERT INTO notes (user_id, note_date, bullet_type, "
|
||
"note_text, priority, refs) VALUES (%s, %s, %s, %s, %s, %s)",
|
||
(_user_id(), target, bullet, note_text, priority, refs),
|
||
)
|
||
flash("Notiz hinzugefügt.", "success")
|
||
else:
|
||
flash("Notiz darf nicht leer sein.", "danger")
|
||
return redirect(url_for("journal.day", date_str=date_str))
|
||
|
||
notes = _active_notes(_user_id(), target)
|
||
|
||
prev = target - timedelta(days=1)
|
||
nxt = target + timedelta(days=1)
|
||
|
||
return render_template(
|
||
"day.html",
|
||
target=target,
|
||
weekday=WEEKDAYS[target.weekday()],
|
||
notes=notes,
|
||
bullet_labels=BULLET_LABELS,
|
||
prev=prev,
|
||
nxt=nxt,
|
||
ref_types=list(REF_TYPE_DISPLAY.keys()),
|
||
)
|
||
|
||
|
||
@journal_bp.route("/note/<int:note_id>/mark", methods=["POST"])
|
||
@login_required
|
||
def mark_done(note_id: int):
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT * FROM notes WHERE id = %s AND user_id = %s",
|
||
(note_id, _user_id()),
|
||
)
|
||
note = cur.fetchone()
|
||
if not note:
|
||
flash("Notiz nicht gefunden.", "danger")
|
||
return redirect(url_for("journal.index"))
|
||
new_status = "done" if note["status"] != "done" else "open"
|
||
cur.execute(
|
||
"UPDATE notes SET status = %s WHERE id = %s",
|
||
(new_status, note_id),
|
||
)
|
||
flash("Status aktualisiert.", "success")
|
||
return redirect(request.referrer or url_for("journal.index"))
|
||
|
||
|
||
@journal_bp.route("/note/<int:note_id>/move", methods=["POST"])
|
||
@login_required
|
||
def move_note(note_id: int):
|
||
"""Verschiebt eine Notiz in einen anderen Tag oder ins Future Log.
|
||
|
||
Verbessert: Beim Verschieben in einen anderen Tag wird KEINE Kopie
|
||
angelegt, sondern der Eintrag selbst auf das neue Datum gesetzt und
|
||
`moved_from_date` gespeichert (Herkunft bleibt nachvollziehbar).
|
||
Formularfelder: target=date|future, target_date=YYYY-MM-DD
|
||
"""
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT * FROM notes WHERE id = %s AND user_id = %s",
|
||
(note_id, _user_id()),
|
||
)
|
||
note = cur.fetchone()
|
||
if not note:
|
||
flash("Notiz nicht gefunden.", "danger")
|
||
return redirect(url_for("journal.index"))
|
||
|
||
target = request.form.get("target", "")
|
||
|
||
if target == "future":
|
||
# In Future Log übernehmen
|
||
refs = note.get("refs")
|
||
cur.execute(
|
||
"INSERT INTO future_log (user_id, month_date, note_text, refs) "
|
||
"VALUES (%s, %s, %s, %s)",
|
||
(_user_id(), note["note_date"], note["note_text"], refs),
|
||
)
|
||
cur.execute(
|
||
"UPDATE notes SET status = 'moved', moved_to_future = 1 "
|
||
"WHERE id = %s",
|
||
(note_id,),
|
||
)
|
||
flash("In Future Log verschoben.", "success")
|
||
else:
|
||
target_date = parse_date(request.form.get("target_date", ""))
|
||
if not target_date:
|
||
flash("Bitte ein Zieldatum auswählen.", "danger")
|
||
elif target_date == note["note_date"]:
|
||
flash("Das Zieldatum entspricht dem aktuellen Tag.", "warning")
|
||
else:
|
||
cur.execute(
|
||
"UPDATE notes SET note_date = %s, status = 'open', "
|
||
"moved_to_future = 0, moved_to_date = NULL, "
|
||
"moved_from_date = %s WHERE id = %s",
|
||
(target_date, note["note_date"], note_id),
|
||
)
|
||
flash(f"Verschoben nach {target_date.strftime('%d.%m.%Y')}.", "success")
|
||
|
||
return redirect(request.referrer or url_for("journal.index"))
|