- 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).
223 lines
9.1 KiB
Python
223 lines
9.1 KiB
Python
"""FlowDeck — cohérence collections ↔ workspace.
|
|
|
|
Une database (`collections`) peut être :
|
|
|
|
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
|
|
* **de page** — page convertie en base (`collections.parent_page_id` +
|
|
`pages.collection_id`) ou base inline insérée dans une page.
|
|
|
|
Ce module centralise les deux sens de la relation :
|
|
|
|
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
|
|
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
|
|
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
|
|
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
|
|
d'un workspace dont tous les documents ont été supprimés ;
|
|
* **écriture** — la suppression en cascade d'une collection. L'ordre des
|
|
écritures est dicté par le schéma SQLite :
|
|
|
|
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
|
|
la page hôte doit être détachée **avant** la suppression ;
|
|
- ``collection_data_sources.source_collection_id`` référence également en
|
|
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
|
|
être détachées ;
|
|
- ``collection_pages`` (lignes), ``collection_properties``,
|
|
``collection_views``, ``collection_permissions`` et les pages contenu
|
|
(``pages.collection_row_id``) partent en ``CASCADE``.
|
|
|
|
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
|
|
dès qu'une page hôte (ou une vue liée) existe encore.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Lecture ──────────────────────────────────────────────────────────────
|
|
|
|
#: SQL du workspace **effectif** d'une collection.
|
|
#:
|
|
#: ``collections.workspace_id`` est renseigné pour les databases créées via
|
|
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
|
|
#: convertie en base, base inline insérée dans un document) : son workspace
|
|
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
|
|
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
|
|
#: ``workspaces`` les exclut définitivement.
|
|
EFFECTIVE_WORKSPACE_SQL = (
|
|
"COALESCE({a}.workspace_id,"
|
|
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
|
|
)
|
|
|
|
|
|
def effective_workspace_sql(alias: str = "c") -> str:
|
|
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
|
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
|
|
|
|
|
def user_workspace_ids(conn, user_id: int) -> list[int]:
|
|
"""IDs des workspaces accessibles à l'utilisateur.
|
|
|
|
Propriétaire **ou** membre : c'est le même périmètre que la recherche
|
|
globale (`app/services/search._scope_where`), à la différence près qu'on
|
|
n'inclut pas les éléments sans workspace — une base non rattachable
|
|
n'appartient à personne et ne doit donc apparaître chez personne.
|
|
"""
|
|
rows = conn.execute(
|
|
"SELECT id FROM workspaces WHERE owner_id = ?"
|
|
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
|
|
(user_id, user_id),
|
|
).fetchall()
|
|
return [r[0] for r in rows]
|
|
|
|
|
|
def live_collection_ids(conn, workspace_id: int) -> list[int]:
|
|
"""IDs des collections rattachées au workspace qui sont encore vivantes.
|
|
|
|
Une collection est exclue dès que sa page hôte a disparu :
|
|
|
|
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
|
|
* une page ``content_format='collection'`` qui pointe encore vers elle
|
|
mais est à la corbeille.
|
|
|
|
Les collections sans page hôte (databases de workspace créées via
|
|
``/db/api``) restent toujours visibles.
|
|
|
|
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
|
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
|
|
"""
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT c.id
|
|
FROM collections c
|
|
WHERE {effective_workspace_sql("c")} = ?
|
|
AND (c.parent_page_id IS NULL
|
|
OR EXISTS (SELECT 1 FROM pages p
|
|
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
|
|
AND NOT EXISTS (SELECT 1 FROM pages p
|
|
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
|
|
ORDER BY c.name
|
|
""",
|
|
(workspace_id,),
|
|
).fetchall()
|
|
return [r["id"] for r in rows]
|
|
|
|
|
|
def live_row_ids(conn, collection_id: int) -> list[int]:
|
|
"""IDs des lignes racines d'une collection dont la page contenu vit.
|
|
|
|
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
|
|
: son absence ne dit rien, mais son passage à la corbeille signifie que
|
|
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
|
|
"""
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT cp.id
|
|
FROM collection_pages cp
|
|
WHERE cp.collection_id = ?
|
|
AND cp.parent_id IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM pages p
|
|
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
|
|
ORDER BY cp.position
|
|
""",
|
|
(collection_id,),
|
|
).fetchall()
|
|
return [r["id"] for r in rows]
|
|
|
|
|
|
def live_document_count(conn, workspace_id: int) -> int:
|
|
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
|
|
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
|
|
return conn.execute(
|
|
"SELECT COUNT(*) FROM pages "
|
|
"WHERE workspace_id = ? AND parent_id IS NULL "
|
|
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
|
(workspace_id,),
|
|
).fetchone()[0]
|
|
|
|
|
|
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
|
|
"""IDs des collections dont l'une de ces pages est la page hôte.
|
|
|
|
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
|
|
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
|
|
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
|
|
aucune collection et ne remontent donc rien.
|
|
|
|
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
|
|
relation ``pages.collection_id``.
|
|
"""
|
|
ids = [int(p) for p in page_ids if p is not None]
|
|
if not ids:
|
|
return []
|
|
qs = ",".join("?" * len(ids))
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT id FROM collections WHERE parent_page_id IN ({qs})
|
|
UNION
|
|
SELECT collection_id FROM pages
|
|
WHERE id IN ({qs}) AND collection_id IS NOT NULL
|
|
""",
|
|
[*ids, *ids],
|
|
).fetchall()
|
|
return [r["id"] for r in rows]
|
|
|
|
|
|
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
|
|
"""``collections_hosted_by_pages`` pour une seule page."""
|
|
return collections_hosted_by_pages(conn, [page_id])
|
|
|
|
|
|
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
|
|
"""IDs de **toutes** les collections du workspace (sans filtre de
|
|
vivacité) — utilisé lors de la suppression du workspace."""
|
|
rows = conn.execute(
|
|
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
|
|
).fetchall()
|
|
return [r["id"] for r in rows]
|
|
|
|
|
|
# ── Écriture ─────────────────────────────────────────────────────────────
|
|
|
|
def delete_collections(conn, collection_ids) -> int:
|
|
"""Supprime définitivement des collections et tout ce qu'elles portent.
|
|
|
|
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
|
|
vues liées) puis supprime : le reste part en ``CASCADE``.
|
|
|
|
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
|
|
transaction. Retourne le nombre de collections supprimées.
|
|
"""
|
|
ids = [int(i) for i in collection_ids if i is not None]
|
|
if not ids:
|
|
return 0
|
|
qs = ",".join("?" * len(ids))
|
|
|
|
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
|
|
# contenu EST la base redevient une page blocs normale.
|
|
conn.execute(
|
|
f"""UPDATE pages
|
|
SET collection_id = NULL,
|
|
content_format = CASE WHEN content_format = 'collection'
|
|
THEN 'blocks' ELSE content_format END
|
|
WHERE collection_id IN ({qs})""",
|
|
ids,
|
|
)
|
|
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
|
|
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
|
|
conn.execute(
|
|
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
|
|
ids,
|
|
)
|
|
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
|
|
# suivent en CASCADE.
|
|
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
|
|
return cur.rowcount or 0
|
|
|
|
|
|
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
|
|
"""Supprime les databases portées par des pages définitivement supprimées
|
|
(corbeille purgée, suppression définitive, workspace effacé)."""
|
|
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
|