fix(listes): exclure les pages-supports de templates des listes utilisateur

Les pages-supports créées par TemplateService._create_holder (une par
template, search_excluded=1, « masquées, jamais listées ») n'étaient
exclues d'aucune liste : semées à l'init avec updated_at = now (99 sur
une base fraîche), elles occupaient toutes les places des listes
triées par récence (LIMIT 50/20/8) devant les vraies pages — cause
racine de 7 échecs CI (library recents/private/workspace, sidebar
recents, projets builtin, mentions agent), dont l'instabilité du test
provenance (égalité de seconde avec le seed).

Filtre COALESCE(search_excluded, 0) = 0 ajouté : onglets bibliothèque
(meetings, recents, favorites, shared, published, private, workspace),
sidebar Recents, projets builtin, scans de pages des mentions agent.
This commit is contained in:
Spark
2026-10-25 22:38:53 +00:00
parent 3c8fc3070a
commit 193fbd14ce
4 changed files with 22 additions and 11 deletions
+5 -3
View File
@@ -838,6 +838,8 @@ def list_mentions(request: Request, q: str = ""):
Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans
titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois. titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois.
""" """
# Les scans de pages excluent les pages-supports de templates
# (search_excluded=1 — cf. library.NO_HOLDER) : jamais mentionnables.
query = (q or "").strip()[:60] query = (q or "").strip()[:60]
like = f"%{query}%" like = f"%{query}%"
limit = 8 limit = 8
@@ -873,7 +875,7 @@ def list_mentions(request: Request, q: str = ""):
if ws: if ws:
sql_ws = ( sql_ws = (
"SELECT p.id, p.title FROM pages p " "SELECT p.id, p.title FROM pages p "
"WHERE p.workspace_id=? AND p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " "WHERE p.workspace_id=? AND p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?" "ORDER BY p.updated_at DESC LIMIT ?"
) )
files = [ files = [
@@ -922,7 +924,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute( for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name " "SELECT p.id, p.title, w.name AS ws_name "
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id " "FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (p.workspace_id IS ? OR p.workspace_id IS NULL) " "WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (p.workspace_id IS ? OR p.workspace_id IS NULL) "
"AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?", "AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?",
(ws, query, like, limit) (ws, query, like, limit)
).fetchall(): ).fetchall():
@@ -939,7 +941,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute( for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name FROM pages p " "SELECT p.id, p.title, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id " "LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " "WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit) "ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit)
).fetchall(): ).fetchall():
docs.append({"type": "document", "icon": "📄", "id": r["id"], docs.append({"type": "document", "icon": "📄", "id": r["id"],
+1
View File
@@ -107,6 +107,7 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
FROM pages p FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash' WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND COALESCE(p.search_excluded, 0) = 0
AND (w.owner_id = ? OR p.workspace_id IS NULL) AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '') AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%' NOT LIKE '%"type":"meeting"%'
+2 -1
View File
@@ -222,7 +222,8 @@ async def list_workspace_projects(request: Request):
builtin = [] builtin = []
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL "
"AND COALESCE(search_excluded, 0) = 0 ORDER BY updated_at DESC LIMIT 20"
).fetchall() ).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne. # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {} counts = {}
+14 -7
View File
@@ -210,6 +210,12 @@ BASE_SELECT = (
"p.parent_id, p.share_mode, p.parent_section, p.page_icon" "p.parent_id, p.share_mode, p.parent_section, p.page_icon"
) )
# Pages-supports de templates (fd_templates._create_holder) : pages internes
# marquées search_excluded=1 — « masquées, jamais listées ». Elles ne doivent
# apparaître dans AUCUN onglet de la bibliothèque (une base fraîche en sème 99,
# qui noieraient sinon recents/private/workspace sous leur updated_at = now).
NO_HOLDER = "COALESCE(p.search_excluded, 0) = 0"
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python). # Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'" MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
@@ -240,7 +246,7 @@ def library_meetings(
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary " " ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id " "FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) " "WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}" f"AND {NO_HOLDER} AND {MEETING_BLOCK_LIKE}"
) )
params: list = [uid] params: list = [uid]
query, params = _apply_source_filter(query, params, source_type) query, params = _apply_source_filter(query, params, source_type)
@@ -275,7 +281,7 @@ def library_recents(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
# Hierarchical view: show only root-level pages at the top # Hierarchical view: show only root-level pages at the top
@@ -312,7 +318,8 @@ def library_favorites(
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, " "SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited " "p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id " "FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" "WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL "
f"AND {NO_HOLDER}"
) )
params: list = [uid] params: list = [uid]
if tree: if tree:
@@ -379,7 +386,7 @@ def library_shared(
query = ( query = (
f"{BASE_SELECT} FROM pages p " f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
) )
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -410,7 +417,7 @@ def library_published(
uid = _get_user_id(request) uid = _get_user_id(request)
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin). # v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
params: list = [] params: list = []
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -435,7 +442,7 @@ def library_private(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -501,7 +508,7 @@ def library_workspace(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
if tree: if tree: