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,222 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user