""" Promemoria + calendario (Apple Reminders/Calendar, via account Microsoft agganciato). Non è un demone: si lancia a mano o a inizio sessione, mai da cron (il vecchio outlook_sync.py e' fallito in silenzio per 17 giorni prima di essere notato). Task (To Do): legge i pendenti da Microsoft Graph, stampa a schermo quelli mai visti (Adrian li smista in conversazione, lì per lì: thread in MEMORY.md, nota in archivio/, o scarto se già gestiti a voce). Lo stato dei promemoria già visti resta in scripts/.reminders_state.json per non riproporli. Calendario: stampa a schermo i prossimi eventi (--calendario). Scrittura (create/update/delete task ed eventi): funzioni libreria, usate via snippet Python al momento del bisogno — vedi .claude/skills/processa-promemoria/. Usage: scripts/reminders_venv/bin/python3 scripts/reminders_sync.py scripts/reminders_venv/bin/python3 scripts/reminders_sync.py --calendario [--giorni N] scripts/reminders_venv/bin/python3 scripts/reminders_sync.py --complete """ import sys import os import json from datetime import datetime, date, timedelta from pathlib import Path import httpx import pytz from dotenv import load_dotenv, set_key ROOT = Path(__file__).resolve().parent.parent load_dotenv(ROOT / '.env') CLIENT_ID = os.getenv('OUTLOOK_CLIENT_ID') CLIENT_SECRET = os.getenv('OUTLOOK_CLIENT_SECRET') REFRESH_TOKEN = os.getenv('OUTLOOK_REFRESH_TOKEN') ENV_FILE = ROOT / '.env' STATE_FILE = Path(__file__).resolve().parent / '.reminders_state.json' TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token' GRAPH_URL = 'https://graph.microsoft.com/v1.0' _LOCAL_TZ = pytz.timezone('Europe/Rome') def _local_date(dt_str: str) -> str: """Graph restituisce dueDateTime/lastModified in UTC — converte in data locale (Europe/Rome) prima di troncare, altrimenti si perde un giorno vicino a mezzanotte.""" if not dt_str: return '' dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) if dt.tzinfo is None: dt = pytz.utc.localize(dt) return dt.astimezone(_LOCAL_TZ).strftime('%Y-%m-%d') def log(msg): print(f'[reminders_sync] {msg}') def get_access_token() -> str: r = httpx.post(TOKEN_URL, data={ 'client_id': CLIENT_ID, 'refresh_token': REFRESH_TOKEN, 'grant_type': 'refresh_token', 'scope': 'Calendars.ReadWrite Tasks.ReadWrite offline_access', }, timeout=15) r.raise_for_status() data = r.json() if 'refresh_token' in data and data['refresh_token'] != REFRESH_TOKEN: set_key(str(ENV_FILE), 'OUTLOOK_REFRESH_TOKEN', data['refresh_token']) return data['access_token'] def graph_get(token: str, path: str, params: dict = None) -> dict: r = httpx.get(f'{GRAPH_URL}{path}', headers={'Authorization': f'Bearer {token}'}, params=params, timeout=30) r.raise_for_status() return r.json() def graph_post(token: str, path: str, body: dict) -> dict: r = httpx.post(f'{GRAPH_URL}{path}', headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, json=body, timeout=30) r.raise_for_status() return r.json() def graph_patch(token: str, path: str, body: dict) -> None: r = httpx.patch(f'{GRAPH_URL}{path}', headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}, json=body, timeout=30) r.raise_for_status() def graph_delete(token: str, path: str) -> None: r = httpx.delete(f'{GRAPH_URL}{path}', headers={'Authorization': f'Bearer {token}'}, timeout=30) if r.status_code not in (204, 404): r.raise_for_status() def complete_task(token: str, list_id: str, task_id: str) -> None: graph_patch(token, f'/me/todo/lists/{list_id}/tasks/{task_id}', {'status': 'completed'}) def default_task_list_id(token: str) -> str: lists_data = graph_get(token, '/me/todo/lists') for lst in lists_data.get('value', []): if lst.get('displayName', '').lower() in ('tasks', 'attività', 'task'): return lst['id'] return lists_data['value'][0]['id'] def list_id_by_name(token: str, name: str) -> str | None: lists_data = graph_get(token, '/me/todo/lists') for lst in lists_data.get('value', []): if lst.get('displayName', '').lower() == name.lower(): return lst['id'] return None def get_or_create_list(token: str, name: str) -> str: existing = list_id_by_name(token, name) if existing: return existing result = graph_post(token, '/me/todo/lists', {'displayName': name}) return result['id'] def create_task(token: str, title: str, due_date: str = None, notes: str = None, list_id: str = None) -> dict: """due_date: 'YYYY-MM-DD'. Ritorna {id, list_id}.""" if not list_id: list_id = default_task_list_id(token) body: dict = {'title': title} if due_date: body['dueDateTime'] = {'dateTime': f'{due_date}T00:00:00', 'timeZone': 'Europe/Rome'} if notes: body['body'] = {'content': notes, 'contentType': 'text'} result = graph_post(token, f'/me/todo/lists/{list_id}/tasks', body) return {'id': result['id'], 'list_id': list_id} def update_task(token: str, list_id: str, task_id: str, title: str = None, due_date: str = None, notes: str = None) -> None: body: dict = {} if title: body['title'] = title if due_date: body['dueDateTime'] = {'dateTime': f'{due_date}T00:00:00', 'timeZone': 'Europe/Rome'} if notes: body['body'] = {'content': notes, 'contentType': 'text'} graph_patch(token, f'/me/todo/lists/{list_id}/tasks/{task_id}', body) def delete_task(token: str, list_id: str, task_id: str) -> None: graph_delete(token, f'/me/todo/lists/{list_id}/tasks/{task_id}') def create_event(token: str, subject: str, start: str, end: str = None, location: str = None, notes: str = None, all_day: bool = False) -> dict: """start/end: 'YYYY-MM-DD HH:MM' (o solo data se all_day). Ritorna evento creato.""" body: dict = {'subject': subject, 'body': {'contentType': 'text', 'content': notes or ''}} if all_day: start_date = start[:10] end_date = end[:10] if end else ( datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=1) ).strftime('%Y-%m-%d') if end is None else end[:10] body['isAllDay'] = True body['start'] = {'dateTime': f'{start_date}T00:00:00', 'timeZone': 'UTC'} body['end'] = {'dateTime': f'{end_date}T00:00:00', 'timeZone': 'UTC'} else: start_dt = start.replace(' ', 'T') + ':00' end_dt = (end.replace(' ', 'T') + ':00') if end else None if not end_dt: h, m = start.split(' ')[1].split(':') end_time = datetime(2000, 1, 1, int(h), int(m)) + timedelta(hours=1) end_dt = f"{start.split(' ')[0]}T{end_time.strftime('%H:%M')}:00" body['start'] = {'dateTime': start_dt, 'timeZone': 'Europe/Rome'} body['end'] = {'dateTime': end_dt, 'timeZone': 'Europe/Rome'} if location: body['location'] = {'displayName': location} return graph_post(token, '/me/events', body) def update_event(token: str, event_id: str, subject: str = None, start: str = None, end: str = None, location: str = None, notes: str = None) -> None: body: dict = {} if subject: body['subject'] = subject if start: body['start'] = {'dateTime': start.replace(' ', 'T') + ':00', 'timeZone': 'Europe/Rome'} if end: body['end'] = {'dateTime': end.replace(' ', 'T') + ':00', 'timeZone': 'Europe/Rome'} if location: body['location'] = {'displayName': location} if notes: body['body'] = {'contentType': 'text', 'content': notes} graph_patch(token, f'/me/events/{event_id}', body) def delete_event(token: str, event_id: str) -> None: graph_delete(token, f'/me/events/{event_id}') def load_state() -> dict: if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text()) return {} def save_state(state: dict) -> None: STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2)) def fetch_pending_tasks(token: str) -> list[dict]: lists_data = graph_get(token, '/me/todo/lists') tasks = [] for lst in lists_data.get('value', []): list_name = lst.get('displayName', '') tasks_data = graph_get(token, f'/me/todo/lists/{lst["id"]}/tasks', {'$top': 100}) for task in tasks_data.get('value', []): if task.get('status') == 'completed': continue due = None if task.get('dueDateTime'): due = _local_date(task['dueDateTime'].get('dateTime', '')) tasks.append({ 'id': task['id'], 'list_id': lst['id'], 'title': task.get('title') or '(senza titolo)', 'list': list_name, 'due': due, 'created': task.get('createdDateTime', '')[:10], }) return tasks def main(): if not all([CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN]): log('Errore: credenziali Outlook mancanti in .env') sys.exit(1) log('Ottengo access token...') token = get_access_token() log('Leggo task pendenti da Microsoft To Do...') tasks = fetch_pending_tasks(token) log(f' → {len(tasks)} task attivi trovati') state = load_state() new_tasks = [t for t in tasks if t['id'] not in state] if not new_tasks: log('Nessun promemoria nuovo.') return log(f'{len(new_tasks)} promemoria nuovi:') for t in new_tasks: log(f' - {t["title"]}' + (f' (scadenza {t["due"]})' if t['due'] else '')) log('(non ancora segnati come processati — vedi mark_processed() dopo averli davvero smistati)') def mark_processed(task_id: str, title: str, list_id: str) -> None: """Segna un task come effettivamente smistato — da chiamare SOLO dopo aver deciso cosa farne (non al momento del fetch/stampa). Bug reale corretto il 09/08/2026: la versione precedente segnava "visto" subito dopo averlo stampato a schermo, dentro main() — se la sessione veniva interrotta prima del triage vero, il promemoria spariva dai "nuovi" per sempre senza che nulla fosse stato deciso davvero. Ora, se il triage non viene completato (mark_processed mai chiamata), il task ricompare come "nuovo" al prossimo fetch — ridondante ma sicuro, meglio ririproporlo che perderlo in silenzio.""" state = load_state() state[task_id] = { 'title': title, 'list_id': list_id, 'seen_at': datetime.now().isoformat(), } save_state(state) def fetch_upcoming_events(token: str, days: int) -> list[dict]: today = date.today() data = graph_get(token, '/me/calendarView', { 'startDateTime': f'{today}T00:00:00Z', 'endDateTime': f'{today + timedelta(days=days)}T23:59:59Z', '$top': '200', '$orderby': 'start/dateTime', }) events = [] for ev in data.get('value', []): if ev.get('type') == 'seriesMaster': continue start_raw = ev['start'].get('dateTime') or ev['start'].get('date', '') if ev.get('isAllDay'): when = start_raw[:10] + ' (tutto il giorno)' elif 'T' in start_raw: dt = datetime.fromisoformat(start_raw.replace('Z', '+00:00')) if dt.tzinfo is None: dt = pytz.utc.localize(dt) dt_local = dt.astimezone(_LOCAL_TZ) when = dt_local.strftime('%Y-%m-%d %H:%M') else: when = start_raw[:10] + ' (tutto il giorno)' events.append({ 'subject': ev.get('subject') or '(senza titolo)', 'when': when, 'location': ev.get('location', {}).get('displayName') or None, }) return events def cli_calendario(days: int) -> None: token = get_access_token() events = fetch_upcoming_events(token, days) log(f'Prossimi {days} giorni — {len(events)} eventi:') for ev in events: loc = f' @ {ev["location"]}' if ev['location'] else '' log(f' {ev["when"]} — {ev["subject"]}{loc}') def cli_complete(task_id: str) -> None: state = load_state() entry = state.get(task_id) if not entry or not entry.get('list_id'): log(f'Errore: id "{task_id}" non trovato in stato locale (serve list_id per completarlo)') sys.exit(1) token = get_access_token() complete_task(token, entry['list_id'], task_id) log(f'Completato su Reminders/Outlook: {entry["title"]}') if __name__ == '__main__': if '--complete' in sys.argv: idx = sys.argv.index('--complete') cli_complete(sys.argv[idx + 1]) elif '--calendario' in sys.argv: n = 14 if '--giorni' in sys.argv: n = int(sys.argv[sys.argv.index('--giorni') + 1]) cli_calendario(n) else: main()