Migrate all repos into monorepo context folders

Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
@@ -0,0 +1,79 @@
import os
from app.db.session import SessionLocal
from app.models import Task
def sync_backlog():
filepath = "BACKLOG.md"
if not os.path.exists(filepath):
return False
db = SessionLocal()
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
table_lines = [l.strip() for l in lines if l.strip().startswith("|")]
if len(table_lines) > 2:
data_lines = table_lines[2:] # Skip header and separator
for line in data_lines:
cols = [c.strip() for c in line.split("|")]
if len(cols) >= 8:
todo = cols[1]
details = cols[2]
ansprechpartner = cols[3]
quelle = cols[4]
deadline = cols[5]
prioritaet = cols[6].lower()
umfang = cols[7]
if not todo:
continue
priority_val = 4
if "sehr hoch" in prioritaet:
priority_val = 1
elif "hoch" in prioritaet:
priority_val = 2
elif "mittel" in prioritaet:
priority_val = 3
# Build a structured description
description_parts = []
if details: description_parts.append(f"Details: {details}")
if ansprechpartner: description_parts.append(f"Ansprechpartner: {ansprechpartner}")
if deadline: description_parts.append(f"Deadline: {deadline}")
if umfang: description_parts.append(f"Umfang: {umfang}")
full_desc = "\n".join(description_parts)
existing_task = db.query(Task).filter(Task.title == todo).first()
if existing_task:
existing_task.description = full_desc
existing_task.origin = quelle
existing_task.priority = priority_val
else:
new_task = Task(
title=todo,
description=full_desc,
origin=quelle,
priority=priority_val,
status="open"
)
db.add(new_task)
db.commit()
return True
except Exception as e:
print(f"Error syncing backlog: {e}")
db.rollback()
return False
finally:
db.close()
return False
if __name__ == "__main__":
success = sync_backlog()
print(f"Sync successful: {success}")
+199
View File
@@ -0,0 +1,199 @@
"""Daily Digest and Weekly Retrospective generation."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import Task, Event, CallItem
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def generate_daily_digest(db: Session) -> dict:
"""Generate a structured daily digest.
Returns a dict with sections: greeting, today_events, top_tasks,
overdue, due_today, new_since_yesterday, stats.
"""
now = datetime.now(BERLIN_TZ)
today_start = datetime.combine(now.date(), datetime.min.time()).replace(tzinfo=BERLIN_TZ)
today_end = datetime.combine(now.date(), datetime.max.time()).replace(tzinfo=BERLIN_TZ)
yesterday = today_start - timedelta(days=1)
# Greeting based on time of day
hour = now.hour
if hour < 12:
greeting = "Good morning"
elif hour < 17:
greeting = "Good afternoon"
else:
greeting = "Good evening"
# Today's events
todays_events = db.query(Event).filter(
Event.start_time >= today_start,
Event.start_time <= today_end
).order_by(Event.start_time).all()
# Top 5 priority tasks
top_tasks = db.query(Task).filter(
Task.status == "open"
).order_by(Task.priority, Task.position).limit(5).all()
# Overdue tasks
overdue = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date < today_start
).order_by(Task.due_date).all()
# Due today
due_today = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date >= today_start,
Task.due_date <= today_end
).all()
# New tasks since yesterday
new_tasks = db.query(Task).filter(
Task.status == "open",
Task.created_at >= yesterday
).count()
# Total open
total_open = db.query(Task).filter(Task.status == "open").count()
# Pending calls
pending_calls = db.query(CallItem).filter(
CallItem.status == "pending"
).all()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
result = {
"greeting": greeting,
"date": now.strftime("%A, %B %d, %Y"),
"total_open": total_open,
"events_today": [
{
"title": e.title,
"time": f"{e.start_time.astimezone(BERLIN_TZ).strftime('%H:%M')} - {e.end_time.astimezone(BERLIN_TZ).strftime('%H:%M')}"
}
for e in todays_events
],
"top_tasks": [
{"id": t.id, "title": t.title, "priority": priority_labels.get(t.priority, "Low")}
for t in top_tasks
],
"overdue": [
{
"id": t.id,
"title": t.title,
"days_overdue": (now.date() - t.due_date.date()).days
}
for t in overdue
],
"due_today": [
{"id": t.id, "title": t.title}
for t in due_today
],
"new_since_yesterday": new_tasks,
"stats": {
"events_count": len(todays_events),
"overdue_count": len(overdue),
"due_today_count": len(due_today),
}
}
# Only include pending_calls section if there are pending calls
if pending_calls:
result["pending_calls"] = [
{"person": c.person, "reason": c.reason}
for c in pending_calls
]
return result
def generate_weekly_retro(db: Session) -> dict:
"""Generate a weekly retrospective summary.
Looks at the past 7 days: what was completed, what slipped,
what's still open, and workload trends.
"""
now = datetime.now(BERLIN_TZ)
week_ago = now - timedelta(days=7)
today_start = datetime.combine(now.date(), datetime.min.time()).replace(tzinfo=BERLIN_TZ)
# Tasks completed this week (check for status change isn't tracked,
# so we look at tasks with status=completed that exist)
completed_this_week = db.query(Task).filter(
Task.status == "completed"
).count()
# Tasks still open
total_open = db.query(Task).filter(Task.status == "open").count()
# Tasks in review
in_review = db.query(Task).filter(Task.status == "review").count()
# Overdue tasks
overdue = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date < today_start
).order_by(Task.due_date).all()
# Tasks created this week
created_this_week = db.query(Task).filter(
Task.created_at >= week_ago,
Task.status.notin_(["dismissed"])
).count()
# Tasks by priority
priority_breakdown = {}
for prio in [1, 2, 3, 4]:
count = db.query(Task).filter(
Task.status == "open", Task.priority == prio
).count()
priority_breakdown[prio] = count
# Tasks by origin
origin_breakdown = db.query(
Task.origin, func.count(Task.id)
).filter(Task.status == "open").group_by(Task.origin).all()
# Events this week
events_this_week = db.query(Event).filter(
Event.start_time >= week_ago,
Event.start_time <= now
).count()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
return {
"period": f"{week_ago.strftime('%b %d')} - {now.strftime('%b %d, %Y')}",
"completed": completed_this_week,
"created": created_this_week,
"total_open": total_open,
"in_review": in_review,
"overdue": [
{
"id": t.id,
"title": t.title,
"days_overdue": (now.date() - t.due_date.date()).days
}
for t in overdue
],
"priority_breakdown": {
priority_labels[k]: v for k, v in priority_breakdown.items()
},
"origin_breakdown": {
(origin or "unknown"): count for origin, count in origin_breakdown
},
"events_attended": events_this_week,
"net_change": created_this_week - completed_this_week,
"health": "growing" if created_this_week > completed_this_week + 5 else
"shrinking" if completed_this_week > created_this_week + 3 else "stable",
}
+339
View File
@@ -0,0 +1,339 @@
import os
import imaplib
import email as email_lib
import traceback
from email.header import decode_header
from app.db.session import SessionLocal
from app.models import Task
# IMAP label constants
LABEL_TODO = "todo"
LABEL_NO_TODO = "no_todo"
LABEL_DONE = "done"
def _get_imap_connection():
server = os.getenv("IMAP_SERVER")
port = int(os.getenv("IMAP_PORT", "993"))
username = os.getenv("EMAIL_USERNAME")
password = os.getenv("EMAIL_PASSWORD")
if not all([server, username, password]):
raise ValueError("IMAP credentials missing. Set IMAP_SERVER, IMAP_PORT, EMAIL_USERNAME, EMAIL_PASSWORD.")
conn = imaplib.IMAP4_SSL(server, port)
conn.login(username, password)
return conn
def _decode_header_value(value) -> str:
if value is None:
return ""
parts = decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(part)
return " ".join(decoded)
def _get_body_snippet(msg, max_chars: int = 300) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and part.get("Content-Disposition") is None:
payload = part.get_payload(decode=True)
if payload:
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")[:max_chars]
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")[:max_chars]
return ""
def _apply_label(conn, uid_bytes: bytes, label: str):
try:
conn.uid("STORE", uid_bytes, "+FLAGS", f"({label})")
except Exception as e:
print(f"Warning: could not apply label '{label}': {e}")
def _remove_label(conn, uid_bytes: bytes, label: str):
try:
conn.uid("STORE", uid_bytes, "-FLAGS", f"({label})")
except Exception as e:
print(f"Warning: could not remove label '{label}': {e}")
def _archive_email(conn, uid_bytes: bytes):
"""Move email to archive folder. Uses IONOS year-based Archiv folders."""
from datetime import datetime
current_year = str(datetime.now().year)
archive_folders = [
f"Archiv/{current_year}", # IONOS: "Archiv/2026"
"Archiv",
"Archive",
"INBOX.Archive",
]
for folder in archive_folders:
try:
result = conn.uid("COPY", uid_bytes, folder)
if result[0] == "OK":
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f"Email archived to: {folder}")
return True
except Exception:
continue
print(f"Could not archive email. Tried: {archive_folders}")
return False
def _delete_email(conn, uid_bytes: bytes):
"""Permanently delete email (move to Papierkorb/Trash)."""
trash_folders = [
"Papierkorb", # IONOS German
"Trash",
"INBOX.Trash",
"Deleted Items",
]
for folder in trash_folders:
try:
result = conn.uid("COPY", uid_bytes, folder)
if result[0] == "OK":
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f"Email moved to trash: {folder}")
return True
except Exception:
continue
# Fallback: just mark as deleted in INBOX
try:
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print("Email deleted from INBOX (no trash folder found)")
return True
except Exception as e:
print(f"Could not delete email: {e}")
return False
def sync_emails():
"""Fetch untracked INBOX emails and create tasks."""
try:
conn = _get_imap_connection()
except ValueError as e:
print(f"IMAP not configured: {e}")
return True
except Exception as e:
print(f"IMAP connection failed: {e}")
traceback.print_exc()
return False
try:
conn.select("INBOX")
# Try keyword-based search, fall back to UNSEEN
try:
status, data = conn.uid(
"SEARCH", None,
f"UNKEYWORD {LABEL_TODO} UNKEYWORD {LABEL_NO_TODO} UNKEYWORD {LABEL_DONE}"
)
except Exception:
print("UNKEYWORD not supported, falling back to UNSEEN")
status, data = conn.uid("SEARCH", None, "UNSEEN")
if status != "OK" or not data[0]:
return True # Nothing to process
uid_list = data[0].split()
db = SessionLocal()
for uid_bytes in uid_list:
try:
_, msg_data = conn.uid("FETCH", uid_bytes, "(RFC822)")
if not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email_lib.message_from_bytes(raw)
message_id = msg.get("Message-ID", "").strip()
if not message_id:
continue
title = _decode_header_value(msg.get("Subject", "(No Subject)"))
body_snippet = _get_body_snippet(msg)
# Parse email send date
from email.utils import parsedate_to_datetime
email_date = None
try:
date_str = msg.get("Date")
if date_str:
email_date = parsedate_to_datetime(date_str)
except Exception:
pass
existing = db.query(Task).filter(Task.source_id == message_id).first()
if existing:
_apply_label(conn, uid_bytes, LABEL_TODO)
continue
max_pos = db.query(Task).filter(Task.status == "open").count()
db.add(Task(
title=title[:200],
description=body_snippet[:2000],
priority=4,
position=max_pos,
status="open",
origin="dhive_email",
source_id=message_id,
source_url=os.getenv("EMAIL_WEBMAIL_URL", ""),
created_at=email_date,
))
_apply_label(conn, uid_bytes, LABEL_TODO)
except Exception as e:
print(f"Error processing email UID {uid_bytes}: {e}")
# Cleanup: dismiss tasks whose emails are no longer in INBOX
open_email_tasks = db.query(Task).filter(
Task.origin == "dhive_email",
Task.status == "open",
Task.source_id != None
).all()
dismissed_count = 0
for task in open_email_tasks:
try:
# Search by Message-ID header
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{task.source_id}"')
if status == "OK" and data[0]:
# Email still in INBOX — keep task open
continue
# Also try without quotes (some servers are picky)
status2, data2 = conn.uid("SEARCH", None, f'HEADER Message-ID {task.source_id}')
if status2 == "OK" and data2[0]:
continue
# Email not found in INBOX — dismiss
task.status = "dismissed"
dismissed_count += 1
except Exception as e:
print(f" Cleanup check failed for {task.source_id[:30]}: {e}")
if dismissed_count:
print(f" Auto-dismissed {dismissed_count} tasks (emails no longer in INBOX)")
db.commit()
db.close()
return True
except Exception as e:
print(f"Email sync error: {e}")
traceback.print_exc()
return False
finally:
try:
conn.logout()
except Exception:
pass
def mark_email_done_and_archive(source_id: str) -> bool:
"""Find an email by Message-ID, mark done, and archive it."""
try:
conn = _get_imap_connection()
except Exception as e:
print(f"IMAP connection failed: {e}")
return False
try:
conn.select("INBOX")
# Try multiple search approaches
uid_bytes = None
# Approach 1: HEADER search with quotes
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
# Approach 2: Without CHARSET
if not uid_bytes:
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
# Approach 3: Search ALL and match manually (slow but reliable)
if not uid_bytes:
print(f" Header search failed, trying full scan for: {source_id[:40]}...")
status, data = conn.uid("SEARCH", None, "ALL")
if status == "OK" and data[0]:
for uid in data[0].split()[-100:]: # Check last 100 emails
try:
_, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (Message-ID)])")
if msg_data and msg_data[0] and source_id.encode() in msg_data[0][1]:
uid_bytes = uid
break
except Exception:
continue
if not uid_bytes:
print(f" Email not found in INBOX: {source_id[:50]}")
return False
print(f" Found email UID: {uid_bytes}")
_remove_label(conn, uid_bytes, LABEL_TODO)
_apply_label(conn, uid_bytes, LABEL_DONE)
result = _archive_email(conn, uid_bytes)
return result
except Exception as e:
print(f"Error archiving email {source_id[:50]}: {e}")
traceback.print_exc()
return False
finally:
try:
conn.logout()
except Exception:
pass
def delete_email_from_server(source_id: str) -> bool:
"""Find an email by Message-ID and permanently delete it (move to Trash)."""
try:
conn = _get_imap_connection()
except Exception as e:
print(f"IMAP connection failed: {e}")
return False
try:
conn.select("INBOX")
# Try multiple search approaches
uid_bytes = None
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
if not uid_bytes:
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
if not uid_bytes:
print(f" Email not found for delete: {source_id[:50]}")
return False
_delete_email(conn, uid_bytes)
return True
except Exception as e:
print(f"Error deleting email {source_id[:50]}: {e}")
return False
finally:
try:
conn.logout()
except Exception:
pass
+290
View File
@@ -0,0 +1,290 @@
"""Gmail IMAP sync — second email source for task ingestion.
Requirements:
- Enable IMAP in Gmail settings (Settings → See all settings → Forwarding and POP/IMAP)
- Generate an App Password at https://myaccount.google.com/apppasswords
(requires 2FA to be enabled on your Google account)
- Set GMAIL_USERNAME and GMAIL_PASSWORD env vars
"""
import os
import imaplib
import email as email_lib
from email.header import decode_header
import traceback
from app.db.session import SessionLocal
from app.models import Task
def _get_gmail_connection():
server = os.getenv("GMAIL_IMAP_SERVER", "imap.gmail.com")
port = int(os.getenv("GMAIL_IMAP_PORT", "993"))
username = os.getenv("GMAIL_USERNAME", "")
password = os.getenv("GMAIL_PASSWORD", "")
if not username or not password:
return None # Not configured, skip silently
conn = imaplib.IMAP4_SSL(server, port)
conn.login(username, password)
return conn
def _decode_header_value(value) -> str:
if value is None:
return ""
parts = decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(part)
return " ".join(decoded)
def _get_body_snippet(msg, max_chars: int = 300) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and part.get("Content-Disposition") is None:
payload = part.get_payload(decode=True)
if payload:
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")[:max_chars]
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")[:max_chars]
return ""
def sync_gmail():
"""Fetch unread emails from Gmail inbox and create tasks.
Returns True on success (or if not configured), False on error.
"""
try:
conn = _get_gmail_connection()
except imaplib.IMAP4.error as e:
print(f"Gmail IMAP auth failed: {e}")
print("Make sure you're using an App Password (not your regular Google password).")
print("Generate one at: https://myaccount.google.com/apppasswords")
return False
except Exception as e:
print(f"Gmail IMAP connection failed: {e}")
traceback.print_exc()
return False
if conn is None:
# Not configured — skip silently
return True
try:
conn.select("INBOX")
# Search for unread emails
status, data = conn.uid("SEARCH", None, "UNSEEN")
if status != "OK" or not data[0]:
conn.logout()
return True # Nothing to process
uid_list = data[0].split()
db = SessionLocal()
synced_count = 0
for uid_bytes in uid_list:
try:
_, msg_data = conn.uid("FETCH", uid_bytes, "(RFC822)")
if not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email_lib.message_from_bytes(raw)
message_id = msg.get("Message-ID", "").strip()
if not message_id:
continue
title = _decode_header_value(msg.get("Subject", "(No Subject)"))
sender = _decode_header_value(msg.get("From", ""))
body_snippet = _get_body_snippet(msg)
# Parse email send date
from email.utils import parsedate_to_datetime
email_date = None
try:
date_str = msg.get("Date")
if date_str:
email_date = parsedate_to_datetime(date_str)
except Exception:
pass
# Skip if already tracked (including dismissed)
existing = db.query(Task).filter(Task.source_id == message_id).first()
if existing:
continue
# Limit title length
title = title[:200] if title else "(No Subject)"
description = f"From: {sender}\n\n{body_snippet}" if sender else body_snippet
max_pos = db.query(Task).filter(Task.status == "open").count()
db.add(Task(
title=title,
description=description[:2000], # Limit description length
priority=4,
position=max_pos,
status="open",
origin="gmail",
source_id=message_id,
source_url="https://mail.google.com/mail/",
created_at=email_date,
))
synced_count += 1
except Exception as e:
print(f"Error processing Gmail UID {uid_bytes}: {e}")
db.commit()
db.close()
print(f"Gmail sync: {synced_count} new tasks from {len(uid_list)} unread emails")
except Exception as e:
print(f"Gmail sync error: {e}")
traceback.print_exc()
return False
finally:
try:
conn.logout()
except Exception:
pass
return True
def gmail_archive_email(source_id: str) -> bool:
"""Archive a Gmail email (remove from Inbox, keep in All Mail)."""
try:
conn = _get_gmail_connection()
except Exception as e:
print(f"Gmail connection failed: {e}")
return False
if conn is None:
return False
try:
conn.select("INBOX")
# Find the email
uid_bytes = _find_gmail_email(conn, source_id)
if not uid_bytes:
print(f" Gmail: email not found: {source_id[:40]}")
return False
# Gmail archive = move to All Mail (remove INBOX label)
# In IMAP terms: COPY to [Gmail]/All Mail, then delete from INBOX
try:
conn.uid("COPY", uid_bytes, "[Gmail]/All Mail")
except Exception:
pass # Might already be in All Mail
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f" Gmail: archived email")
return True
except Exception as e:
print(f"Gmail archive error: {e}")
return False
finally:
try:
conn.logout()
except Exception:
pass
def gmail_delete_email(source_id: str) -> bool:
"""Delete a Gmail email (move to Trash)."""
try:
conn = _get_gmail_connection()
except Exception as e:
print(f"Gmail connection failed: {e}")
return False
if conn is None:
return False
try:
conn.select("INBOX")
uid_bytes = _find_gmail_email(conn, source_id)
if not uid_bytes:
print(f" Gmail: email not found for delete: {source_id[:40]}")
return False
# Move to Gmail Trash
conn.uid("COPY", uid_bytes, "[Gmail]/Trash")
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f" Gmail: deleted email")
return True
except Exception as e:
print(f"Gmail delete error: {e}")
return False
finally:
try:
conn.logout()
except Exception:
pass
def _find_gmail_email(conn, source_id: str):
"""Find a Gmail email by Message-ID. Returns UID bytes or None."""
# Try HEADER search
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID "{source_id}"')
if status == "OK" and data[0]:
return data[0].split()[0]
# Try without quotes
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
if status == "OK" and data[0]:
return data[0].split()[0]
return None
def gmail_cleanup_dismissed(db_session=None):
"""Check if Gmail tasks are still in INBOX, dismiss if not."""
try:
conn = _get_gmail_connection()
except Exception:
return
if conn is None:
return
try:
conn.select("INBOX")
db = db_session or SessionLocal()
open_gmail_tasks = db.query(Task).filter(
Task.origin == "gmail",
Task.status == "open",
Task.source_id != None
).all()
dismissed = 0
for task in open_gmail_tasks:
uid = _find_gmail_email(conn, task.source_id)
if not uid:
task.status = "dismissed"
dismissed += 1
if dismissed:
db.commit()
print(f" Gmail: auto-dismissed {dismissed} tasks")
if not db_session:
db.close()
except Exception as e:
print(f"Gmail cleanup error: {e}")
finally:
try:
conn.logout()
except Exception:
pass
@@ -0,0 +1,325 @@
import os
import caldav
import json
import traceback
from datetime import datetime
from zoneinfo import ZoneInfo
from app.db.session import SessionLocal
from app.models import Event, Task
# Berlin timezone for display
BERLIN_TZ = ZoneInfo("Europe/Berlin")
UTC_TZ = ZoneInfo("UTC")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_caldav_principal():
url = os.getenv("NEXTCLOUD_URL")
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
if not all([url, username, password]):
raise ValueError("Nextcloud credentials missing. Set NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD.")
caldav_url = url if url.endswith("/remote.php/dav/") else f"{url.rstrip('/')}/remote.php/dav/"
client = caldav.DAVClient(url=caldav_url, username=username, password=password)
return client.principal()
def _to_berlin(dt):
"""Convert a datetime to Berlin timezone. Handles naive and aware datetimes."""
if dt is None:
return None
if not isinstance(dt, datetime):
# date-only: treat as midnight Berlin time
return datetime.combine(dt, datetime.min.time()).replace(tzinfo=BERLIN_TZ)
if dt.tzinfo is None:
# Naive datetime — assume UTC
dt = dt.replace(tzinfo=UTC_TZ)
return dt.astimezone(BERLIN_TZ)
def _is_own_calendar(cal_url_str: str) -> bool:
"""Check if a calendar belongs to the authenticated user (not shared by others)."""
return "_shared_by_" not in cal_url_str
def _get_nextcloud_base_url() -> str:
"""Get the base Nextcloud URL for building links."""
url = os.getenv("NEXTCLOUD_URL", "")
return url.rstrip("/")
# ---------------------------------------------------------------------------
# Calendar events
# ---------------------------------------------------------------------------
def sync_calendar_events():
try:
principal = _get_caldav_principal()
calendars = principal.calendars()
except Exception as e:
print(f"Failed to connect to Nextcloud CalDAV: {e}")
return False
db = SessionLocal()
for calendar in calendars:
cal_url_str = str(calendar.url)
if not _is_own_calendar(cal_url_str):
print(f"Skipping shared calendar: {calendar.get_display_name()}")
continue
try:
events = calendar.events()
for event in events:
ical = event.icalendar_component
if ical.name == "VEVENT":
title = str(ical.get("SUMMARY", "Untitled Event"))
dtstart = ical.get("DTSTART")
dtend = ical.get("DTEND")
if not dtstart or not dtend:
continue
start_time = _to_berlin(dtstart.dt)
end_time = _to_berlin(dtend.dt)
existing = db.query(Event).filter(Event.title == title, Event.source == "nextcloud").first()
if not existing:
db.add(Event(title=title, start_time=start_time, end_time=end_time, source="nextcloud"))
except Exception as e:
print(f"Error reading calendar {calendar}: {e}")
db.commit()
db.close()
return True
# ---------------------------------------------------------------------------
# ToDo sync (two-way, only own tasks)
# ---------------------------------------------------------------------------
def _parse_vtodo_datetime(val):
"""Parse a VTODO datetime and convert to Berlin timezone."""
if val is None:
return None
return _to_berlin(val.dt)
def _has_local_changes(task, remote_title: str, remote_description: str, remote_status: str) -> bool:
"""Check if the local task differs from what was last synced (i.e., user edited locally)."""
# If never synced, treat as no local changes (first sync)
if task.last_synced_at is None:
return False
# If the task was modified after last sync, local has changes
# We compare local fields against remote to detect divergence
local_title = task.title or ""
local_desc = task.description or ""
local_status = task.status or "open"
# If local differs from remote, and we have a last_synced_at, local was changed
if local_title != remote_title:
return True
if local_desc != (remote_description or ""):
return True
if local_status != remote_status:
return True
return False
def _has_remote_changes(task, remote_title: str, remote_description: str, remote_status: str) -> bool:
"""Check if remote data differs from what's currently stored locally.
This detects whether the remote side changed since our last sync.
If the remote values differ from what we have locally, remote changed.
"""
local_title = task.title or ""
local_desc = task.description or ""
local_status = task.status or "open"
if local_title != remote_title:
return True
if local_desc != (remote_description or ""):
return True
if local_status != remote_status:
return True
return False
def sync_todo_tasks():
"""Import VTODOs from own Nextcloud todo lists into local Task table.
Only imports from calendars owned by the authenticated user (skips shared).
Detects conflicts when both local and remote changed since last sync.
"""
try:
principal = _get_caldav_principal()
calendars = principal.calendars()
except Exception as e:
print(f"Failed to connect to Nextcloud CalDAV: {e}")
return False
db = SessionLocal()
nc_username = os.getenv("NEXTCLOUD_USERNAME", "")
base_url = _get_nextcloud_base_url()
for calendar in calendars:
cal_url_str = str(calendar.url)
cal_name = calendar.get_display_name() or "Tasks"
# Skip shared calendars (these belong to coworkers)
if not _is_own_calendar(cal_url_str):
print(f"Skipping shared todo list: {cal_name}")
continue
try:
todos = calendar.todos(include_completed=True)
except Exception:
continue # calendar may not support VTODOs
for todo in todos:
try:
ical = todo.icalendar_component
if ical.name != "VTODO":
continue
uid = str(ical.get("UID", ""))
if not uid:
continue
title = str(ical.get("SUMMARY", "Untitled Task"))
description = str(ical.get("DESCRIPTION", "") or "")
due_date = _parse_vtodo_datetime(ical.get("DUE"))
vtodo_status = str(ical.get("STATUS", "NEEDS-ACTION")).upper()
status = "completed" if vtodo_status == "COMPLETED" else "open"
# Build origin label with calendar name
origin_label = f"nextcloud_todo:{cal_name}"
# Build link to Nextcloud tasks app
source_url = f"{base_url}/apps/tasks/#/calendars/{cal_name}" if base_url else None
existing = db.query(Task).filter(Task.source_id == uid).first()
if existing:
# Don't touch dismissed or completed tasks (user already handled them)
if existing.status in ("dismissed", "completed"):
continue
# --- Conflict detection ---
# Check if local was modified since last sync
local_changed = _has_local_changes(existing, title, description, status)
remote_changed = _has_remote_changes(existing, title, description, status)
if local_changed and remote_changed:
# Both sides changed → conflict
existing.has_conflict = True
existing.conflict_data = json.dumps({
"title": title,
"status": status,
"description": description,
})
continue
if not remote_changed:
# Only local changed (or nothing changed) → push local to remote if needed
if local_changed and existing.source_id:
push_updates = {}
if existing.title != title:
push_updates["title"] = existing.title
if existing.description != (description or ""):
push_updates["description"] = existing.description
if existing.status != status:
push_updates["status"] = existing.status
if push_updates:
update_remote_todo(existing.source_id, push_updates)
existing.last_synced_at = datetime.now(tz=UTC_TZ)
continue
# Only remote changed → update local normally
# Don't revert in_progress or review tasks back to open
if existing.status in ("in_progress", "review") and status == "open":
existing.title = title
existing.description = description
existing.due_date = due_date
existing.origin = origin_label
existing.source_url = source_url
existing.last_synced_at = datetime.now(tz=UTC_TZ)
continue
existing.title = title
existing.description = description
existing.due_date = due_date
existing.status = status
existing.origin = origin_label
existing.source_url = source_url
existing.last_synced_at = datetime.now(tz=UTC_TZ)
else:
max_pos = db.query(Task).count()
db.add(Task(
title=title,
description=description,
priority=4,
position=max_pos,
status=status,
origin=origin_label,
source_id=uid,
source_url=source_url,
due_date=due_date,
last_synced_at=datetime.now(tz=UTC_TZ),
))
except Exception as e:
print(f"Error processing VTODO: {e}")
db.commit()
db.close()
return True
def update_remote_todo(source_id: str, updates: dict):
"""Push local task changes back to the Nextcloud server.
updates: dict with any of: title, description, status
"""
try:
principal = _get_caldav_principal()
calendars = principal.calendars()
except Exception as e:
print(f"Failed to connect to Nextcloud CalDAV for push: {e}")
return False
for calendar in calendars:
cal_url_str = str(calendar.url)
# Only push to own calendars
if not _is_own_calendar(cal_url_str):
continue
try:
todos = calendar.todos(include_completed=True)
except Exception:
continue
for todo in todos:
try:
ical = todo.icalendar_component
if ical.name != "VTODO":
continue
if str(ical.get("UID", "")) != source_id:
continue
# Apply updates
if "title" in updates:
ical["SUMMARY"] = updates["title"]
if "description" in updates:
ical["DESCRIPTION"] = updates["description"]
if "status" in updates:
ical["STATUS"] = "COMPLETED" if updates["status"] == "completed" else "NEEDS-ACTION"
todo.save()
return True
except Exception as e:
print(f"Error updating remote VTODO {source_id}: {e}")
print(f"Remote VTODO with UID {source_id} not found.")
return False
@@ -0,0 +1,207 @@
"""Phone number extraction and normalization for German telephone formats.
Supports:
- +49 international format (e.g., +49 30 12345678, +49-171-1234567)
- 0xxx domestic format (e.g., 030 12345678, 0171-1234567)
- (0xxx) parenthesized area code format (e.g., (030) 12345678, (0171) 1234567)
"""
import re
# Pattern 1: +49 international format
# +49 followed by 9 to 12 digits with optional spaces or hyphens
_INTERNATIONAL_PATTERN = re.compile(
r"\+49[\s\-]?(\d[\d\s\-]{8,14}\d)"
)
# Pattern 2: 0xxx domestic format
# 0 followed by area code (2-5 digits) then subscriber (4-8 digits), with optional separators
_DOMESTIC_PATTERN = re.compile(
r"(?<!\()0(\d{2,5})[\s\-/]?(\d[\d\s\-]{3,9}\d)"
)
# Pattern 3: (0xxx) parenthesized area code format
# (0xxx) followed by subscriber digits with optional separators
_PARENTHESIZED_PATTERN = re.compile(
r"\(0(\d{2,5})\)[\s\-]?(\d[\d\s\-]{3,9}\d)"
)
def extract_phone_number(text: str) -> str | None:
"""Extract the first German phone number from text.
Supports formats:
- +49 xxx xxxx xxxx (international)
- 0xxx xxxxxxx (domestic with area code)
- (0xxx) xxxxxxx (domestic with parenthesized area code)
Returns the raw matched string or None if no match found.
"""
if not text:
return None
# Collect all matches with their start positions to find the first one
matches: list[tuple[int, str]] = []
for m in _INTERNATIONAL_PATTERN.finditer(text):
matches.append((m.start(), m.group(0)))
for m in _PARENTHESIZED_PATTERN.finditer(text):
matches.append((m.start(), m.group(0)))
for m in _DOMESTIC_PATTERN.finditer(text):
# Avoid matching numbers already captured by parenthesized pattern
start = m.start()
raw = m.group(0)
# Check this isn't inside parentheses
if start > 0 and text[start - 1] == "(":
continue
matches.append((start, raw))
if not matches:
return None
# Return the first match by position
matches.sort(key=lambda x: x[0])
return matches[0][1]
def normalize_phone_number(raw: str) -> str:
"""Normalize a phone number by removing separators and standardizing format.
Strips spaces, hyphens, slashes, and parentheses.
Converts domestic format (leading 0) to international (+49) format.
Returns the normalized number string (e.g., "+4930123456").
"""
if not raw:
return raw
# Remove all separators: spaces, hyphens, slashes, parentheses
cleaned = re.sub(r"[\s\-/\(\)]", "", raw)
# Convert domestic (0xxx) to international (+49xxx)
if cleaned.startswith("0") and not cleaned.startswith("+"):
cleaned = "+49" + cleaned[1:]
return cleaned
def parse_phone_number(text: str) -> dict | None:
"""Parse a phone number string into components.
Returns a dict with keys: country_code, area_code, subscriber
or None if the text cannot be parsed as a German phone number.
"""
if not text:
return None
# First try to extract a phone number from the text
raw = extract_phone_number(text)
if raw is None:
# Try parsing the text directly as a phone number
normalized = normalize_phone_number(text)
if not normalized.startswith("+49"):
return None
digits_after_country = normalized[3:]
if not digits_after_country or len(digits_after_country) < 5:
return None
# Use heuristic: area codes are 2-5 digits, rest is subscriber
area_code, subscriber = _split_area_subscriber(digits_after_country)
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
# Parse the extracted raw number
# Try international pattern
m = _INTERNATIONAL_PATTERN.match(raw)
if m:
digits = re.sub(r"[\s\-]", "", m.group(1))
area_code, subscriber = _split_area_subscriber(digits)
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
# Try parenthesized pattern
m = _PARENTHESIZED_PATTERN.match(raw)
if m:
area_code = m.group(1)
subscriber = re.sub(r"[\s\-]", "", m.group(2))
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
# Try domestic pattern
m = _DOMESTIC_PATTERN.match(raw)
if m:
area_code = m.group(1)
subscriber = re.sub(r"[\s\-]", "", m.group(2))
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
return None
def _split_area_subscriber(digits: str) -> tuple[str, str]:
"""Split a digit string into area code and subscriber number.
Uses known German area code lengths:
- 2 digits: major cities (30=Berlin, 40=Hamburg, etc.)
- 3 digits: large cities (211=Düsseldorf, 221=Köln, etc.)
- 4 digits: medium cities and mobile prefixes (171, 172, etc.)
- 5 digits: smaller areas
Heuristic: if total digits <= 10, use shorter area code.
"""
# Known 2-digit area codes (major cities)
two_digit_codes = {"30", "40", "69", "89"}
# Known 3-digit mobile prefixes
three_digit_mobile = {
"151", "152", "153", "155", "156", "157", "159",
"160", "162", "163", "170", "171", "172", "173",
"174", "175", "176", "177", "178", "179",
}
# Check 2-digit area codes
if len(digits) >= 4 and digits[:2] in two_digit_codes:
return digits[:2], digits[2:]
# Check 3-digit codes (mobile and city)
if len(digits) >= 5 and digits[:3] in three_digit_mobile:
return digits[:3], digits[3:]
# Check common 3-digit city codes
three_digit_city = {
"211", "212", "221", "228", "231", "241", "251",
"261", "271", "281", "291",
"311", "321", "331", "341", "351", "361", "371", "381", "391",
"421", "431", "441", "451", "461", "471", "481",
"511", "521", "531", "541", "551", "561", "571", "581", "591",
"611", "621", "631", "641", "651", "661", "671", "681",
"711", "721", "731", "741", "751", "761", "771",
"811", "821", "831", "841", "851", "861", "871",
"911", "921", "931", "941", "951", "961", "971", "981", "991",
}
if len(digits) >= 5 and digits[:3] in three_digit_city:
return digits[:3], digits[3:]
# Default heuristic based on total length
if len(digits) <= 8:
# Short number: assume 3-digit area code
return digits[:3], digits[3:]
elif len(digits) <= 10:
# Medium: assume 4-digit area code
return digits[:4], digits[4:]
else:
# Long: assume 4-digit area code
return digits[:4], digits[4:]
@@ -0,0 +1,140 @@
"""Prioritization Engine — generates actionable suggestions based on task state."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.models import Task
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def generate_suggestions(db: Session) -> list[dict]:
"""Analyze open tasks and generate prioritization suggestions.
Returns a list of suggestion dicts with: id, content, type, severity.
"""
now = datetime.now(BERLIN_TZ)
today = now.date()
suggestions = []
suggestion_id = 1
open_tasks = db.query(Task).filter(Task.status == "open").all()
if not open_tasks:
return []
# --- 1. Overdue tasks ---
overdue_tasks = [
t for t in open_tasks
if t.due_date and t.due_date.date() < today
]
for t in overdue_tasks[:3]: # Limit to top 3
days_overdue = (today - t.due_date.date()).days
suggestions.append({
"id": suggestion_id,
"content": f"⚠️ \"{t.title}\" is {days_overdue} day{'s' if days_overdue != 1 else ''} overdue. Reschedule or complete it.",
"type": "overdue",
"severity": "high",
"task_id": t.id,
})
suggestion_id += 1
# --- 2. Due tomorrow ---
tomorrow = today + timedelta(days=1)
due_tomorrow = [
t for t in open_tasks
if t.due_date and t.due_date.date() == tomorrow
]
for t in due_tomorrow:
suggestions.append({
"id": suggestion_id,
"content": f"📅 \"{t.title}\" is due tomorrow.",
"type": "upcoming",
"severity": "medium",
"task_id": t.id,
})
suggestion_id += 1
# --- 3. Due this week ---
week_end = today + timedelta(days=7)
due_this_week = [
t for t in open_tasks
if t.due_date and today < t.due_date.date() <= week_end
and t.due_date.date() != tomorrow # Already covered above
]
if len(due_this_week) > 3:
suggestions.append({
"id": suggestion_id,
"content": f"📋 You have {len(due_this_week)} tasks due this week. Plan your time.",
"type": "workload",
"severity": "medium",
})
suggestion_id += 1
# --- 4. High-priority tasks without due dates ---
high_no_date = [
t for t in open_tasks
if t.priority and t.priority <= 2 and not t.due_date
]
for t in high_no_date[:2]: # Limit to top 2
suggestions.append({
"id": suggestion_id,
"content": f"🎯 \"{t.title}\" is high priority but has no deadline. Set a due date.",
"type": "missing_date",
"severity": "medium",
"task_id": t.id,
})
suggestion_id += 1
# --- 5. Tasks idle for 7+ days ---
idle_threshold = now - timedelta(days=7)
idle_tasks = [
t for t in open_tasks
if t.created_at and t.created_at < idle_threshold
and (not t.due_date) # Only flag idle tasks without deadlines
and t.priority and t.priority >= 3 # Low/medium priority
]
if len(idle_tasks) > 5:
suggestions.append({
"id": suggestion_id,
"content": f"🧹 {len(idle_tasks)} low-priority tasks have been sitting for over a week. Review and clean up.",
"type": "stale",
"severity": "low",
})
suggestion_id += 1
elif idle_tasks:
oldest = max(idle_tasks, key=lambda t: (now - t.created_at).days if t.created_at else 0)
if oldest.created_at:
days_idle = (now - oldest.created_at).days
suggestions.append({
"id": suggestion_id,
"content": f"🧹 \"{oldest.title}\" has been open for {days_idle} days. Still relevant?",
"type": "stale",
"severity": "low",
"task_id": oldest.id,
})
suggestion_id += 1
# --- 6. Too many high-priority tasks ---
high_priority_count = len([t for t in open_tasks if t.priority and t.priority <= 2])
if high_priority_count > 5:
suggestions.append({
"id": suggestion_id,
"content": f"🔥 You have {high_priority_count} high-priority tasks. If everything is urgent, nothing is. Consider demoting some.",
"type": "overload",
"severity": "medium",
})
suggestion_id += 1
# --- 7. No tasks due soon (might need planning) ---
tasks_with_dates = [t for t in open_tasks if t.due_date]
if len(open_tasks) > 10 and len(tasks_with_dates) < 3:
suggestions.append({
"id": suggestion_id,
"content": "📆 Most of your tasks have no due dates. Consider scheduling your top priorities.",
"type": "planning",
"severity": "low",
})
suggestion_id += 1
return suggestions