"""Alfred Listes V1 — courses et tâches locales, SQLite, commandes vocales sûres. Aucune dépendance externe. Le serveur MCP n'attend jamais et ne lance jamais de shell. """ from __future__ import annotations from datetime import datetime, timezone from contextlib import contextmanager import os from pathlib import Path import re import sqlite3 import threading import unicodedata from urllib.parse import urlparse from flask import Blueprint, jsonify, request DB_PATH = Path(os.getenv('ALFRED_LISTS_DB', str(Path(__file__).resolve().parents[1] / 'data' / 'alfred_lists.sqlite3'))) LOCK = threading.RLock() KINDS = {'courses': 'courses', 'course': 'courses', 'taches': 'taches', 'tache': 'taches', 'tasks': 'taches', 'task': 'taches', 'todo': 'taches', 'shopping': 'courses'} MAX_ITEMS = 500 MAX_LEN = 160 def fold(s): return ''.join(c for c in unicodedata.normalize('NFKD', s.casefold()) if not unicodedata.combining(c)) def normalize_kind(kind): if not isinstance(kind, str) or fold(kind.strip()) not in KINDS: raise ValueError('Liste inconnue : choisir courses ou taches.') return KINDS[fold(kind.strip())] def clean_label(label, kind=None): if not isinstance(label, str): raise ValueError('Le nom de l’élément doit être un texte.') label = ' '.join(label.split()).strip(' .!?;,') if kind == 'courses': label = re.sub(r"^(?:du\s+|de\s+la\s+|des\s+|de\s+l['’])", '', label, flags=re.I).strip() if not label or len(label) > MAX_LEN or any(ord(x) < 32 for x in label): raise ValueError('Nom attendu : de 1 à 160 caractères.') return label def _conn(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(DB_PATH), timeout=6) conn.row_factory = sqlite3.Row conn.execute('PRAGMA busy_timeout=6000') conn.execute('PRAGMA journal_mode=WAL') conn.execute('''CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL CHECK(kind IN ('courses','taches')), label TEXT NOT NULL, normalized TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(kind, normalized) )''') return conn @contextmanager def _db(): with LOCK: conn = _conn() try: with conn: yield conn finally: conn.close() def _row(row): return {'id': row['id'], 'kind': row['kind'], 'label': row['label'], 'done': bool(row['done']), 'created_at': row['created_at']} def list_items(kind=None): if kind is not None: kind = normalize_kind(kind) with _db() as conn: q = ('SELECT id, kind, label, done, created_at FROM items ' + ('WHERE kind=? ' if kind else '') + 'ORDER BY done ASC, id DESC LIMIT 1000') rows = conn.execute(q, (kind,) if kind else ()).fetchall() items = [_row(r) for r in rows] result = {'courses': [], 'taches': []} for item in items: result[item['kind']].append(item) return {'ok': True, 'lists': result, 'counts': {k: {'active': sum(not r['done'] for r in v), 'total': len(v)} for k, v in result.items()}} def add_item(kind, label): kind = normalize_kind(kind) label = clean_label(label, kind) norm = fold(label) now = datetime.now(timezone.utc).isoformat() with _db() as conn: row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, norm)).fetchone() if row: if row['done']: conn.execute('UPDATE items SET done=0, label=?, updated_at=? WHERE id=?', (label, now, row['id'])) existing = False else: existing = True item_id = row['id'] else: n = conn.execute('SELECT COUNT(*) FROM items WHERE kind=?', (kind,)).fetchone()[0] if n >= MAX_ITEMS: raise ValueError('Liste pleine (500 éléments maximum).') item_id = conn.execute('INSERT INTO items (kind,label,normalized,done,created_at,updated_at) VALUES (?,?,?,?,?,?)', (kind, label, norm, 0, now, now)).lastrowid existing = False row = conn.execute('SELECT * FROM items WHERE id=?', (item_id,)).fetchone() return {'ok': True, 'item': _row(row), 'already_present': existing} def _resolve(conn, kind, item_id=None, label=None): kind = normalize_kind(kind) if item_id is not None: try: n = int(item_id) except (ValueError, TypeError): raise ValueError('Identifiant invalide.') row = conn.execute('SELECT * FROM items WHERE id=? AND kind=?', (n, kind)).fetchone() elif label is not None: label = clean_label(label,kind) row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, fold(label))).fetchone() else: raise ValueError('Indique l’identifiant ou le nom exact de l’élément.') if row is None: raise ValueError('Élément introuvable dans la liste.') return row def change_item(action, kind, item_id=None, label=None): if action not in ('complete_item', 'reopen_item', 'delete_item'): raise ValueError('Action inconnue.') with _db() as conn: row = _resolve(conn, kind, item_id, label) if action == 'delete_item': conn.execute('DELETE FROM items WHERE id=?', (row['id'],)) else: conn.execute('UPDATE items SET done=?, updated_at=? WHERE id=?', (int(action == 'complete_item'), datetime.now(timezone.utc).isoformat(), row['id'])) return {'ok': True, 'item': _row(row), 'action': action} def clear_completed(kind): kind = normalize_kind(kind) with _db() as conn: count = conn.execute('DELETE FROM items WHERE kind=? AND done=1', (kind,)).rowcount return {'ok': True, 'deleted': count} def execute(action, data): if action == 'list_items': return list_items(data.get('kind')) if action == 'add_item': return add_item(data.get('kind'), data.get('label')) if action in ('complete_item', 'reopen_item', 'delete_item'): return change_item(action, data.get('kind'), data.get('id'), data.get('label')) if action == 'clear_completed': return clear_completed(data.get('kind')) raise ValueError('Action inconnue.') # Routes vocales directes pour les cas courants : les LLM ne peuvent pas inventer le succès. _ADD_RE = re.compile( r"^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(?P