diff --git a/backend/api/cloud/browse.py b/backend/api/cloud/browse.py index 383c7b6..9a8a32c 100644 --- a/backend/api/cloud/browse.py +++ b/backend/api/cloud/browse.py @@ -25,7 +25,8 @@ from api.cloud.schemas import ( CloudItemOut, FolderFreshnessOut, ) -from db.models import CloudConnection, CloudItem, CloudFolderState, User +from db.models import CloudConnection, CloudItem, CloudFolderState, CloudItemTopic, Topic, User +from sqlalchemy import select from deps.auth import get_regular_user from deps.db import get_db from deps.utils import parse_uuid @@ -73,7 +74,19 @@ def _capability_out(caps: dict) -> dict[str, CloudCapabilityOut]: } -def _item_out(item: CloudItem) -> CloudItemOut: +def _item_out( + item: CloudItem, + topic_names: list[str] | None = None, +) -> CloudItemOut: + """Build a CloudItemOut from a CloudItem ORM row. + + Phase 14.1: topic_names and analysis_status/is_stale are populated from + a caller-supplied batched topic lookup so we avoid N+1 queries. + Folder rows always receive topic_names=[] and analysis_status=None. + extracted_text is deliberately excluded (T-14.1-10 — row payload must stay lightweight). + """ + is_file = item.kind == "file" + status = item.analysis_status if is_file else None return CloudItemOut( id=str(item.id), provider_item_id=item.provider_item_id, @@ -85,9 +98,40 @@ def _item_out(item: CloudItem) -> CloudItemOut: modified_at=item.modified_at, etag=item.etag, capabilities={}, # Phase 13 will add per-item capabilities + # Phase 14.1 row-parity fields (T-14.1-10 allowlist) + topics=topic_names or [], + analysis_status=status, + is_stale=(status == "stale"), ) +async def _batch_load_topics( + session, + file_ids: list, +) -> dict: + """Batch-load topic names for a list of CloudItem UUIDs. + + Returns a dict mapping cloud_item_id (UUID) → list[str] of topic names. + Uses a single JOIN query to avoid N+1 when building browse rows (T-14.1-10). + """ + if not file_ids: + return {} + + stmt = ( + select(CloudItemTopic.cloud_item_id, Topic.name) + .join(Topic, Topic.id == CloudItemTopic.topic_id) + .where(CloudItemTopic.cloud_item_id.in_(file_ids)) + .order_by(CloudItemTopic.cloud_item_id, Topic.name) + ) + result = await session.execute(stmt) + rows = result.fetchall() + + topics_by_item: dict = {} + for cloud_item_id, topic_name in rows: + topics_by_item.setdefault(cloud_item_id, []).append(topic_name) + return topics_by_item + + def _freshness_out(fs: CloudFolderState) -> FolderFreshnessOut: return FolderFreshnessOut( refresh_state=fs.refresh_state, @@ -325,12 +369,20 @@ async def browse_connection_items( display_name = conn.display_name_override or conn.display_name + # Phase 14.1: Batch-load topic names for all file rows in one query (T-14.1-10). + # Folder rows are excluded from the topic lookup (folders never carry topics). + file_ids = [item.id for item in cached_items if item.kind == "file"] + topics_by_item = await _batch_load_topics(session, file_ids) + return CloudBrowseResponse( connection_id=str(connection_id), provider=conn.provider, display_name=display_name, parent_ref=parent_ref, - items=[_item_out(item) for item in cached_items], + items=[ + _item_out(item, topic_names=topics_by_item.get(item.id)) + for item in cached_items + ], capabilities=_capability_out(conn_caps), freshness=_freshness_out(folder_state), ) diff --git a/backend/api/cloud/schemas.py b/backend/api/cloud/schemas.py index a8d9806..5b8c991 100644 --- a/backend/api/cloud/schemas.py +++ b/backend/api/cloud/schemas.py @@ -98,7 +98,18 @@ class CloudCapabilityOut(BaseModel): class CloudItemOut(BaseModel): - """Normalized cloud item metadata. No credentials or byte content.""" + """Normalized cloud item metadata. No credentials or byte content. + + Phase 14.1 (Plan 04) adds lightweight row fields for browser parity: + topics: List of topic-name strings (empty list for unanalyzed/folders). + analysis_status: Internal status string ("pending"|"indexed"|"stale"|"failed"|…) + — None for folder rows. + is_stale: True when analysis_status == "stale" (D-07 convenience flag). + + Strict allowlist: extracted_text, object_key, credentials_enc, version_key, and + raw provider URLs are absent by design (T-14.1-10 — row payload must stay lightweight + and free of sensitive internal fields). + """ id: str # DocuVault stable UUID provider_item_id: str @@ -111,6 +122,11 @@ class CloudItemOut(BaseModel): etag: Optional[str] = None capabilities: dict[str, CloudCapabilityOut] = {} + # Phase 14.1 row-parity fields (T-14.1-10 allowlist — no extracted_text) + topics: List[str] = [] # topic names; empty for folders/unanalyzed + analysis_status: Optional[str] = None # None for folder rows + is_stale: bool = False # True when analysis_status == "stale" (D-07) + # ── Freshness / folder state schemas ───────────────────────────────────────── diff --git a/frontend/src/components/storage/StorageBrowser.vue b/frontend/src/components/storage/StorageBrowser.vue index 3a17ff0..c7e9468 100644 --- a/frontend/src/components/storage/StorageBrowser.vue +++ b/frontend/src/components/storage/StorageBrowser.vue @@ -604,28 +604,81 @@
{{ file.original_name ?? file.name }}
Shared -