250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""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<label>.+?)\s+"
|
||
r"(?:a|à|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?"
|
||
r"(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
|
||
_VIEW_RE = re.compile(
|
||
r"^\s*(?:(?:affiche|montre|lis|donne|consulte)\s+)?"
|
||
r"(?:(?:moi|nous)\s+)?(?:(?:qu['’]est.ce\s+qu['’]il\s+y\s+a\s+(?:dans|sur)\s+)|(?:quelle\s+est\s+))?"
|
||
r"(?:ma|la|notre)\s+liste\s+(?:de\s+)?(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
|
||
|
||
|
||
def parse_voice(text):
|
||
"""Return (action, kind, label) for unambiguous commands only."""
|
||
if not isinstance(text, str): return None
|
||
# Case insensitive + accent insensitive, but keep original item spelling.
|
||
normalized = fold(text).replace('’', "'")
|
||
m = _ADD_RE.match(normalized)
|
||
if m:
|
||
# Re-extract original label by matching an accent-tolerant prefix/suffix.
|
||
orig = re.match(r'^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(.+?)\s+(?:à|a|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?(?:courses?|taches?|tâches?|todo)\s*[.!?]*\s*$',text,re.I)
|
||
label = orig.group(1) if orig else m.group('label')
|
||
return ('add_item', normalize_kind(m.group('kind')), label)
|
||
m = _VIEW_RE.match(normalized)
|
||
if m: return ('list_items', normalize_kind(m.group('kind')), None)
|
||
return None
|
||
|
||
|
||
def handle_voice(text, notify=None):
|
||
parsed = parse_voice(text)
|
||
if not parsed: return None
|
||
action, kind, label = parsed
|
||
result = execute(action, {'kind': kind, 'label': label})
|
||
if action == 'add_item':
|
||
item = result['item']['label']
|
||
dest = 'liste de courses' if kind == 'courses' else 'liste de tâches'
|
||
message = (f'{item} est déjà dans ta {dest}.' if result['already_present']
|
||
else f"J’ai ajouté {item} à ta {dest}.")
|
||
if not result['already_present'] and notify: notify()
|
||
return message
|
||
active = [x['label'] for x in result['lists'][kind] if not x['done']]
|
||
if not active: return 'Ta liste de courses est vide.' if kind == 'courses' else 'Ta liste de tâches est vide.'
|
||
category = 'courses' if kind == 'courses' else 'tâches'
|
||
readable = ', '.join(active[:12])
|
||
return f'Voici ta liste de {category} : {readable}' + (f', et {len(active)-12} autres.' if len(active)>12 else '.')
|
||
|
||
|
||
def _authorized_write():
|
||
if not request.is_json: return False
|
||
# local MCP path: no remote client may spoof this header
|
||
if request.remote_addr in ('127.0.0.1','::1') and request.headers.get('X-Alfred-MCP-Local') == 'lists':
|
||
return True
|
||
if request.headers.get('X-Alfred-Request') != '1': return False
|
||
origin = request.headers.get('Origin', '')
|
||
if origin:
|
||
u = urlparse(origin)
|
||
return u.scheme in ('http','https') and u.netloc == request.host
|
||
return request.remote_addr in ('127.0.0.1','::1')
|
||
|
||
|
||
def register_lists(app, socketio):
|
||
bp = Blueprint('alfred_lists_v1', __name__)
|
||
|
||
@bp.get('/api/alfred/lists')
|
||
def get_lists():
|
||
try: return jsonify(list_items())
|
||
except (OSError, sqlite3.Error) as exc:
|
||
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
|
||
|
||
@bp.post('/api/alfred/lists/action')
|
||
def action_lists():
|
||
if not _authorized_write(): return jsonify(ok=False, error='Requête non autorisée.'), 403
|
||
data = request.get_json(silent=True)
|
||
if not isinstance(data, dict): return jsonify(ok=False, error='Objet JSON attendu.'), 400
|
||
action = data.get('action')
|
||
try:
|
||
result = execute(action, data)
|
||
except ValueError as exc:
|
||
return jsonify(ok=False, error=str(exc)), 400
|
||
except (sqlite3.Error, OSError) as exc:
|
||
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
|
||
if action != 'list_items' and not result.get('already_present'):
|
||
socketio.emit('alfred_lists_changed', {'kind': data.get('kind'), 'action': action})
|
||
return jsonify(result)
|
||
|
||
app.register_blueprint(bp)
|