feat: group private apps into andreknie-privat
This commit is contained in:
@@ -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}")
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user