diff --git a/.gitignore b/.gitignore index 4369b33..3043ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ /static/theme.css /transcription_config.yml -/messages.json \ No newline at end of file +/messages.json +/calendar.db \ No newline at end of file diff --git a/app.py b/app.py index 0af1ddf..5311148 100755 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import os +import sqlite3 # Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader FLASK_ENV = os.environ.get('FLASK_ENV', 'production') @@ -25,6 +26,7 @@ import re import qrcode import base64 import json +import io import search import auth import analytics as a @@ -32,6 +34,7 @@ import folder_secret_config_editor as fsce import helperfunctions as hf import search_db_analyzer as sdb import fnmatch +import openpyxl app_config = auth.return_app_config() BASE_DIR = os.path.realpath(app_config['BASE_DIR']) @@ -86,6 +89,7 @@ app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_adm # Messages/News routes MESSAGES_FILENAME = 'messages.json' +CALENDAR_DB = 'calendar.db' def load_messages(): """Load messages from JSON file.""" @@ -100,6 +104,45 @@ def save_messages(messages): with open(MESSAGES_FILENAME, 'w', encoding='utf-8') as f: json.dump(messages, f, ensure_ascii=False, indent=2) + +def init_calendar_db(): + """Ensure calendar table exists.""" + conn = sqlite3.connect(CALENDAR_DB) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS calendar_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + time TEXT, + title TEXT NOT NULL, + location TEXT, + details TEXT, + created_at TEXT NOT NULL + ) + """ + ) + # Ensure details column exists for legacy DBs + cols = [r[1] for r in conn.execute("PRAGMA table_info(calendar_entries)").fetchall()] + if 'details' not in cols: + conn.execute("ALTER TABLE calendar_entries ADD COLUMN details TEXT") + conn.commit() + conn.commit() + conn.close() + + +def serialize_calendar_row(row): + return { + 'id': row['id'], + 'date': row['date'], + 'time': row['time'] or '', + 'title': row['title'], + 'location': row['location'] or '', + 'details': row['details'] or '' + } + + +init_calendar_db() + @app.route('/api/messages', methods=['GET']) @auth.require_secret def get_messages(): @@ -158,6 +201,245 @@ def delete_message(message_id): return jsonify({'success': True}) + +@app.route('/api/calendar/', methods=['PUT']) +@auth.require_admin +def update_calendar_entry(entry_id): + """Update a calendar entry.""" + data = request.get_json() or {} + entry_date = data.get('date') + title = data.get('title') + if not entry_date or not title: + return jsonify({'error': 'date and title are required'}), 400 + + time_value = data.get('time') or '' + location = data.get('location') or '' + details = data.get('details') or '' + + conn = sqlite3.connect(CALENDAR_DB) + conn.row_factory = sqlite3.Row + cur = conn.execute( + """ + UPDATE calendar_entries + SET date = ?, time = ?, title = ?, location = ?, details = ? + WHERE id = ? + """, + (entry_date, time_value, title, location, details, entry_id) + ) + if cur.rowcount == 0: + conn.close() + return jsonify({'error': 'not found'}), 404 + + row = conn.execute( + "SELECT id, date, time, title, location, details FROM calendar_entries WHERE id = ?", + (entry_id,) + ).fetchone() + conn.commit() + conn.close() + return jsonify(serialize_calendar_row(row)) + + +@app.route('/api/calendar/', methods=['DELETE']) +@auth.require_admin +def delete_calendar_entry(entry_id): + """Delete a calendar entry.""" + conn = sqlite3.connect(CALENDAR_DB) + cur = conn.execute("DELETE FROM calendar_entries WHERE id = ?", (entry_id,)) + conn.commit() + conn.close() + if cur.rowcount == 0: + return jsonify({'error': 'not found'}), 404 + return jsonify({'success': True}) + + +@app.route('/api/calendar/export', methods=['GET']) +@auth.require_admin +def export_calendar(): + """Export all calendar entries to an Excel file.""" + conn = sqlite3.connect(CALENDAR_DB) + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT date, time, title, location, details + FROM calendar_entries + ORDER BY date ASC, time ASC + """ + ).fetchall() + conn.close() + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Kalender" + headers = ["date", "time", "title", "location", "details"] + ws.append(headers) + + def fmt_date(date_str): + try: + return datetime.strptime(date_str, '%Y-%m-%d').strftime('%d.%m.%Y') + except Exception: + return date_str + + for row in rows: + ws.append([ + fmt_date(row['date']), + row['time'] or '', + row['title'], + row['location'] or '', + row['details'] or '' + ]) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + response = make_response(output.getvalue()) + response.headers['Content-Disposition'] = 'attachment; filename=kalender.xlsx' + response.headers['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + return response + + +@app.route('/api/calendar/import', methods=['POST']) +@auth.require_admin +def import_calendar(): + """Import calendar entries from an uploaded Excel file, replacing existing entries.""" + if 'file' not in request.files or request.files['file'].filename == '': + return jsonify({'error': 'No file uploaded'}), 400 + + file = request.files['file'] + try: + wb = openpyxl.load_workbook(file, data_only=True) + except Exception as e: + return jsonify({'error': f'Invalid Excel file: {e}'}), 400 + + ws = wb.active + headers = [str(cell.value).strip().lower() if cell.value else '' for cell in next(ws.iter_rows(min_row=1, max_row=1, values_only=False))] + required = {'date', 'title'} + optional = {'time', 'location', 'details'} + all_allowed = required | optional + + header_map = {} + for idx, h in enumerate(headers): + if h in all_allowed and h not in header_map: + header_map[h] = idx + + if not required.issubset(set(header_map.keys())): + return jsonify({'error': 'Header must include at least date and title columns'}), 400 + + entries = [] + for row in ws.iter_rows(min_row=2, values_only=True): + entry = {} + for key, col_idx in header_map.items(): + value = row[col_idx] if col_idx < len(row) else '' + entry[key] = value if value is not None else '' + # Normalize + entry_date_raw = entry.get('date', '') + entry_date = str(entry_date_raw).strip() + if not entry_date: + continue + try: + # Allow Excel date cells + if isinstance(entry_date_raw, (datetime, date)): + entry_date = entry_date_raw.strftime('%Y-%m-%d') + elif '.' in entry_date: + entry_date = datetime.strptime(entry_date, '%d.%m.%Y').strftime('%Y-%m-%d') + else: + # Expect yyyy-mm-dd + datetime.strptime(entry_date, '%Y-%m-%d') + except Exception: + return jsonify({'error': f'Invalid date format: {entry_date}'}), 400 + + entries.append({ + 'date': entry_date, + 'time': str(entry.get('time', '') or '').strip(), + 'title': str(entry.get('title', '') or '').strip(), + 'location': str(entry.get('location', '') or '').strip(), + 'details': str(entry.get('details', '') or '').strip() + }) + + conn = sqlite3.connect(CALENDAR_DB) + try: + cur = conn.cursor() + cur.execute("DELETE FROM calendar_entries") + cur.executemany( + """ + INSERT INTO calendar_entries (date, time, title, location, details, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + [ + ( + e['date'], + e['time'], + e['title'], + e['location'], + e['details'], + datetime.utcnow().isoformat() + ) for e in entries + ] + ) + conn.commit() + finally: + conn.close() + + return jsonify({'success': True, 'count': len(entries)}) + +@app.route('/api/calendar', methods=['GET']) +@auth.require_secret +def get_calendar_entries(): + """Return calendar entries between start and end date (inclusive).""" + start = request.args.get('start') or date.today().isoformat() + end = request.args.get('end') or (date.today() + timedelta(weeks=4)).isoformat() + + conn = sqlite3.connect(CALENDAR_DB) + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT id, date, time, title, location, details + FROM calendar_entries + WHERE date BETWEEN ? AND ? + ORDER BY date ASC, time ASC + """, + (start, end) + ).fetchall() + conn.close() + + return jsonify([serialize_calendar_row(r) for r in rows]) + + +@app.route('/api/calendar', methods=['POST']) +@auth.require_admin +def create_calendar_entry(): + """Store a new calendar entry.""" + data = request.get_json() or {} + entry_date = data.get('date') + title = data.get('title') + if not entry_date or not title: + return jsonify({'error': 'date and title are required'}), 400 + + time_value = data.get('time') or '' + location = data.get('location') or '' + details = data.get('details') or '' + + conn = sqlite3.connect(CALENDAR_DB) + cur = conn.execute( + """ + INSERT INTO calendar_entries (date, time, title, location, details, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (entry_date, time_value, title, location, details, datetime.utcnow().isoformat()) + ) + conn.commit() + new_id = cur.lastrowid + conn.close() + + return jsonify({ + 'id': new_id, + 'date': entry_date, + 'time': time_value, + 'title': title, + 'location': location, + 'details': details + }), 201 + # Grab the HOST_RULE environment variable host_rule = os.getenv("HOST_RULE", "") # Use a regex to extract domain names between backticks in patterns like Host(`something`) diff --git a/static/app.css b/static/app.css index 11a1fa6..a10872f 100644 --- a/static/app.css +++ b/static/app.css @@ -431,6 +431,12 @@ footer { } /* Messages Section Styles */ +.messages-section-header { + max-width: 900px; + margin: 0 auto; + padding: 0 0 8px; +} + .messages-container { max-width: 900px; margin: 0 auto; @@ -635,6 +641,159 @@ footer { font-size: 1.2em; } +/* Calendar Section */ +#calendar-section .container { + max-width: none; + width: 100%; + padding-left: 20px; + padding-right: 20px; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(1, minmax(0, 1fr)); + gap: 0; + border: 1px solid #ddd; + border-top: 0; + border-radius: 0 0 6px 6px; + overflow: hidden; +} + +.calendar-weekday-header { + display: none; +} + +.calendar-day { + background: var(--card-background, #fff); + border-bottom: 1px solid #ddd; + padding: 8px 10px 4px; +} + +.calendar-day-today { + border: 2px solid #4da3ff; + padding: 6px 8px 2px; +} + +.calendar-day:last-child { + border-bottom: none; +} + +@media (min-width: 992px) { + .calendar-grid { + grid-template-columns: repeat(7, minmax(0, 1fr)); + border-radius: 0 0 8px 8px; + } + + .calendar-weekday-header { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + text-align: center; + font-weight: 600; + color: #999; + border: 1px solid #ddd; + border-bottom: 0; + border-radius: 8px 8px 0 0; + overflow: hidden; + } + + .calendar-day { + border-bottom: 1px solid #ddd; + border-right: 1px solid #ddd; + } + + .calendar-day:nth-child(7n) { + border-right: none; + } + + .calendar-day-name { + display: none; + } +} + +.calendar-day-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + gap: 8px; +} + +.calendar-day-name { + font-weight: 600; + text-transform: capitalize; +} + +.calendar-day-date { + color: #666; + font-size: 0.95rem; + white-space: nowrap; +} + +.calendar-day table { + margin-bottom: 0; + table-layout: auto; + width: 100%; +} + +.calendar-day thead th { + background: #f1f1f1; + color: #666; + border-color: #ddd; +} + +.calendar-day.empty thead { + display: none; +} + +.calendar-entries-list td { + vertical-align: middle; +} + +@media (min-width: 992px) { + .calendar-table-wrapper { + box-sizing: border-box; + margin-left: -12px; + margin-right: -12px; + max-height: 400px; + overflow-y: auto; + overflow-x: auto; + padding: 0 12px; + } +} + +.calendar-day .btn-group .btn { + padding: 2px 8px; +} + +.details-indicator { + font-weight: 700; + color: #666; + margin-left: 6px; + cursor: pointer; + position: relative; + top: -1px; +} + +.calendar-actions-col { + width: 1%; + white-space: nowrap; +} + +.calendar-details-text { + white-space: pre-wrap; + margin: 0; +} + +.calendar-details-row td { + background: #f8f9fa; +} + +.calendar-inline-actions { + margin-top: 8px; + display: flex; + gap: 8px; +} + /* File Browser Styles */ .file-browser-content { max-height: 400px; @@ -674,4 +833,4 @@ footer { margin-top: 0; margin-bottom: 10px; color: #333; -} \ No newline at end of file +} diff --git a/templates/app.html b/templates/app.html index 68853f0..e226bda 100644 --- a/templates/app.html +++ b/templates/app.html @@ -65,6 +65,7 @@ @@ -85,6 +86,7 @@ {% include 'messages_section.html' %} + {% include 'calendar_section.html' %}
@@ -253,59 +255,6 @@
- - - diff --git a/templates/calendar_section.html b/templates/calendar_section.html new file mode 100644 index 0000000..6cff064 --- /dev/null +++ b/templates/calendar_section.html @@ -0,0 +1,605 @@ + +
+
+
+

Kalender

+
+ + {% if admin_enabled %} +
+ + +
+ + + {% endif %} +
+
+ +
+
Mo
+
Di
+
Mi
+
Do
+
Fr
+
Sa
+
So
+
+ +
+ +
+
+
+ + + + + diff --git a/templates/messages_section.html b/templates/messages_section.html index cda834f..66bd75d 100644 --- a/templates/messages_section.html +++ b/templates/messages_section.html @@ -1,6 +1,17 @@
+
+

Nachrichten

+ {% if admin_enabled %} +
+ +
+ {% endif %} +
+ {% if not admin_enabled %} {% if admin_enabled %}
{% endif %} + {% endif %}