fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
"""FlowDeck — My Tasks : sources de tâches et normalisation.
|
||||
|
||||
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
|
||||
explicite** (``collections.is_task``) et le mapping explicite de ses trois
|
||||
propriétés requises :
|
||||
|
||||
=========================== ==========================================
|
||||
Rôle Colonne sur ``collections``
|
||||
=========================== ==========================================
|
||||
``assignee`` (Assigné à) ``task_assignee_prop``
|
||||
``status`` (Statut) ``task_status_prop``
|
||||
``due`` (Échéance) ``task_due_prop``
|
||||
=========================== ==========================================
|
||||
|
||||
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
|
||||
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
|
||||
``collect_tasks`` — donc une même tâche ne peut pas être rendue
|
||||
différemment selon l'appelant.
|
||||
|
||||
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
|
||||
pas seulement le workspace actif — le tableau de bord est un poste de
|
||||
travail transverse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import unicodedata
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from app.services.collection_lifecycle import (
|
||||
effective_workspace_sql,
|
||||
live_collection_ids,
|
||||
live_row_ids,
|
||||
user_workspace_ids,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
|
||||
MAX_TASK_SOURCES = 10
|
||||
|
||||
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
|
||||
TASK_ROLES = ("assignee", "status", "due")
|
||||
|
||||
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
|
||||
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
|
||||
ROLE_TYPES = {
|
||||
"assignee": ("person",),
|
||||
"status": ("status", "select"),
|
||||
"due": ("date",),
|
||||
}
|
||||
|
||||
ROLE_LABELS = {
|
||||
"assignee": "Assigné à",
|
||||
"status": "Statut",
|
||||
"due": "Date d'échéance",
|
||||
}
|
||||
|
||||
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
|
||||
#: sans accents). On compare des formes normalisées et non des libellés exacts :
|
||||
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
|
||||
#: reconnus, quelle que soit la casse et l'accentuation.
|
||||
#:
|
||||
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
|
||||
#: commence comme « clos » mais n'est pas un statut terminé.
|
||||
DONE_WORDS = frozenset({
|
||||
# anglais
|
||||
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
|
||||
# français (masculin / féminin, singulier / pluriel)
|
||||
"clos", "close", "termine", "terminee", "termines", "terminees",
|
||||
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
|
||||
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
|
||||
})
|
||||
|
||||
|
||||
def _normalize(value) -> str:
|
||||
"""Minuscules, accents retirés, espaces compactés."""
|
||||
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||
return " ".join(text.lower().split())
|
||||
|
||||
|
||||
def is_done(status: str) -> bool:
|
||||
"""Le statut vaut-il « terminé » ?
|
||||
|
||||
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
|
||||
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
|
||||
"""
|
||||
norm = _normalize(status)
|
||||
if not norm:
|
||||
return False
|
||||
first = norm.split()[0] if norm.split() else ""
|
||||
return first.strip("!?.,:;()") in DONE_WORDS
|
||||
|
||||
|
||||
def user_today(conn, user_id: int) -> date:
|
||||
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
|
||||
|
||||
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
|
||||
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
|
||||
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
|
||||
l'inverse) dès que l'utilisateur est loin d'UTC.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT timezone FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||
if tz_name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
return datetime.now(ZoneInfo(tz_name)).date()
|
||||
except Exception: # fuseau inconnu / base tz absente → UTC
|
||||
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
|
||||
return datetime.now(UTC).date()
|
||||
|
||||
|
||||
def task_source_columns() -> str:
|
||||
"""Colonnes de ``collections`` lues par les requêtes de sources.
|
||||
|
||||
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
|
||||
base antérieure elles vaudront NULL de toute façon.
|
||||
|
||||
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
|
||||
qualification, ``id`` est ambigu.
|
||||
"""
|
||||
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
|
||||
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
|
||||
|
||||
|
||||
def list_task_sources(conn, user_id: int) -> list[dict]:
|
||||
"""Bases de tâches configurées par l'utilisateur, tous workspaces
|
||||
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
|
||||
``missing_roles`` pour piloter l'invite de conversion.
|
||||
|
||||
Deux exclusions :
|
||||
|
||||
* une base dont la page hôte est à la corbeille (ou supprimée) est
|
||||
**retirée** : sans ce filtre, une base portée par un document supprimé
|
||||
continuerait d'alimenter My Tasks — le bug d'origine ;
|
||||
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
|
||||
pas listée (une base sans workspace rattachable n'appartient à personne).
|
||||
|
||||
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
|
||||
celui de la page hôte) : une base créée depuis un document a
|
||||
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
|
||||
My Tasks alors qu'elle venait d'être convertie.
|
||||
"""
|
||||
ws_ids = user_workspace_ids(conn, user_id)
|
||||
if not ws_ids:
|
||||
return []
|
||||
qs = ",".join("?" * len(ws_ids))
|
||||
ws_sql = effective_workspace_sql("c")
|
||||
rows = conn.execute(
|
||||
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
|
||||
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
|
||||
AS workspace_name
|
||||
FROM collections c
|
||||
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
|
||||
ORDER BY c.name""",
|
||||
[*ws_ids],
|
||||
).fetchall()
|
||||
|
||||
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
|
||||
# jeu de bases vivantes qu'une fois par workspace.
|
||||
live_by_ws: dict[int, set[int]] = {}
|
||||
sources = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ws = d.get("ws_effective")
|
||||
if ws not in live_by_ws:
|
||||
live_by_ws[ws] = set(live_collection_ids(conn, ws))
|
||||
if d["id"] not in live_by_ws[ws]:
|
||||
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
|
||||
continue
|
||||
|
||||
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||
d["configured"] = not missing
|
||||
d["missing_roles"] = missing
|
||||
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
|
||||
sources.append(d)
|
||||
return sources
|
||||
|
||||
|
||||
def _status_options(conn, prop_id) -> list[str]:
|
||||
"""Libellés du statut de la base source (colonnes du Kanban)."""
|
||||
if not prop_id:
|
||||
return []
|
||||
row = conn.execute(
|
||||
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
try:
|
||||
opts = json.loads(row["options_json"] or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
|
||||
if isinstance(o, str) or isinstance(o, dict)]
|
||||
|
||||
|
||||
def source_state(conn, collection_id: int) -> dict:
|
||||
"""État de conversion d'une base pour la modale « convertir en base de
|
||||
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
|
||||
coll = conn.execute(
|
||||
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
return {"exists": False}
|
||||
|
||||
d = dict(coll)
|
||||
props = conn.execute(
|
||||
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
|
||||
"WHERE collection_id=? ORDER BY position, id",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"collection_id": collection_id,
|
||||
"name": d.get("name"),
|
||||
"is_task": bool(d.get("is_task")),
|
||||
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
|
||||
"missing_roles": missing,
|
||||
"configured": not missing,
|
||||
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
|
||||
# autre rôle est exclue — Notion n'autorise pas le double emploi).
|
||||
"candidates": {
|
||||
role: [
|
||||
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||
for p in props
|
||||
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
|
||||
]
|
||||
for role in TASK_ROLES
|
||||
},
|
||||
"all_properties": [
|
||||
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||
for p in props
|
||||
],
|
||||
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
|
||||
"""Nombre de bases déjà connectées (pour la limite de 10)."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
|
||||
(workspace_id,),
|
||||
).fetchone()
|
||||
n = row[0] if row else 0
|
||||
if exclude_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
|
||||
).fetchone()
|
||||
if row and row[0]:
|
||||
n -= 1
|
||||
return max(n, 0)
|
||||
|
||||
|
||||
def _row_value(raw: dict, prop_id):
|
||||
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
|
||||
lignes historicisées écrites avant le passage aux clés indexées)."""
|
||||
if prop_id is None:
|
||||
return None
|
||||
if prop_id in raw:
|
||||
return raw[prop_id]
|
||||
return raw.get(str(prop_id))
|
||||
|
||||
|
||||
def _person_values(value) -> list:
|
||||
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
|
||||
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
out: list = []
|
||||
for v in value:
|
||||
out.extend(_person_values(v))
|
||||
return out
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value]
|
||||
return []
|
||||
|
||||
|
||||
def _person_label(v) -> str:
|
||||
if isinstance(v, dict):
|
||||
return str(v.get("login") or v.get("full_name") or "")
|
||||
return str(v)
|
||||
|
||||
|
||||
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
|
||||
"""L'assigné est-il l'utilisateur courant ?
|
||||
|
||||
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
|
||||
priorité (stable), puis le login en repli pour les lignes historicisées
|
||||
qui ne stockent qu'un nom.
|
||||
"""
|
||||
if not login:
|
||||
return False
|
||||
for v in values:
|
||||
if isinstance(v, dict):
|
||||
if v.get("id") is not None and str(v["id"]) == str(user_id):
|
||||
return True
|
||||
if _person_label(v).strip().lower() == login.strip().lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
|
||||
login: str = "") -> list[dict]:
|
||||
"""Tâches normalisées de toutes les bases connectées.
|
||||
|
||||
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
|
||||
les identifiants des propriétés à écrire pour une édition en direct
|
||||
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
|
||||
deviner quoi écrire.
|
||||
"""
|
||||
tasks: list[dict] = []
|
||||
today = user_today(conn, user_id)
|
||||
|
||||
for src in list_task_sources(conn, user_id):
|
||||
if not src["configured"]:
|
||||
# Base connectée mais mapping incomplet : elle n'alimente pas
|
||||
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
|
||||
continue
|
||||
status_prop = src["task_status_prop"]
|
||||
due_prop = src["task_due_prop"]
|
||||
assignee_prop = src["task_assignee_prop"]
|
||||
|
||||
for row_id in live_row_ids(conn, src["id"]):
|
||||
row = conn.execute(
|
||||
"SELECT id, title, icon, created_at, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
persons = _person_values(_row_value(raw, assignee_prop))
|
||||
assignees = [_person_label(p) for p in persons if _person_label(p)]
|
||||
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
|
||||
continue
|
||||
|
||||
status = _row_value(raw, status_prop)
|
||||
if isinstance(status, list):
|
||||
status = status[0] if status else ""
|
||||
status = str(status or "")
|
||||
|
||||
due_raw = _row_value(raw, due_prop)
|
||||
due = str(due_raw or "")[:10]
|
||||
|
||||
due_state = ""
|
||||
if due:
|
||||
try:
|
||||
d = date.fromisoformat(due)
|
||||
except ValueError:
|
||||
d = None
|
||||
if d:
|
||||
due_state = ("overdue" if d < today
|
||||
else "today" if d == today
|
||||
else "upcoming")
|
||||
|
||||
tasks.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"] or "Untitled",
|
||||
"icon": row["icon"] or "📄",
|
||||
"created_at": row["created_at"],
|
||||
"collection_id": src["id"],
|
||||
"collection_name": src["name"],
|
||||
"collection_icon": src["icon"] or "📋",
|
||||
"workspace_id": src["workspace_id"],
|
||||
"workspace_name": src["workspace_name"],
|
||||
"assignees": assignees,
|
||||
"status": status,
|
||||
"status_options": src["status_options"],
|
||||
"done": is_done(status),
|
||||
"due": due,
|
||||
"due_state": due_state,
|
||||
"status_prop": status_prop,
|
||||
"due_prop": due_prop,
|
||||
"assignee_prop": assignee_prop,
|
||||
})
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
|
||||
sort: str = "due", today: date | None = None) -> list[dict]:
|
||||
"""Filtres et tri globaux de My Tasks (portés par l'URL).
|
||||
|
||||
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
|
||||
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
|
||||
« cette semaine » ne cibleront pas le même jour que `due_state`.
|
||||
"""
|
||||
out = tasks
|
||||
if hide_done:
|
||||
out = [t for t in out if not t["done"]]
|
||||
if due and due != "all":
|
||||
if due == "today":
|
||||
out = [t for t in out if t["due_state"] == "today"]
|
||||
elif due == "overdue":
|
||||
out = [t for t in out if t["due_state"] in ("today", "overdue")]
|
||||
elif due == "week":
|
||||
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
|
||||
# datée dans six mois apparaîtrait sous « Cette semaine ».
|
||||
ref = today or datetime.now(UTC).date()
|
||||
limit = (ref + timedelta(days=7)).isoformat()
|
||||
out = [t for t in out
|
||||
if t["due"] and (t["due_state"] in ("today", "overdue")
|
||||
or t["due"] <= limit)]
|
||||
|
||||
if sort == "status":
|
||||
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
|
||||
(t["due"] or "9999")))
|
||||
if sort == "source":
|
||||
return sorted(out, key=lambda t: (t["collection_name"].lower(),
|
||||
(t["due"] or "9999"), t["title"].lower()))
|
||||
if sort == "created":
|
||||
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
|
||||
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
|
||||
# les tâches sans date — comme une liste de rappels.
|
||||
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
|
||||
t["done"], t["title"].lower()))
|
||||
Reference in New Issue
Block a user