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 - + + + + + + + +
+ - - + {{ cloudRowStatusLabel(file.analysis_status) }} +
@@ -762,6 +815,12 @@ import DropZone from '../upload/DropZone.vue' import UploadProgress from '../upload/UploadProgress.vue' import TopicBadge from '../topics/TopicBadge.vue' import { formatDate, formatSize } from '../../utils/formatters.js' +import { useCloudConnectionsStore } from '../../stores/cloudConnections.js' + +// D-06: Single status translation source — store owns the canonical map. +// StorageBrowser delegates to this; the local translateStatus function below +// also delegates so existing call-sites are preserved. +const cloudStore = useCloudConnectionsStore() // ── CapabilityButton inline component ────────────────────────────────────────── /** @@ -1018,21 +1077,67 @@ const internalSelectedItems = ref(new Set(props.selectedItems ?? [])) /** Expand/collapse state for the analysis item queue list. */ const analysisQueueExpanded = ref(false) -/** D-06: Internal simplified status translation for display. */ +/** + * D-06: Status translation delegates to the store's translateAnalysisStatus. + * The store is the single source of truth for internal status → UI label mapping + * (CLAUDE.md: "translateAnalysisStatus in cloudConnections store is the single + * translation source — components never translate internal status strings + * independently"). + * + * The function signature is preserved so existing call-sites (statusBadgeClass, + * etc.) continue to work without modification. + */ function translateStatus(status) { - const SIMPLIFIED = { - queued: 'waiting', - downloading: 'working', - extracting: 'working', - classifying: 'working', - indexed: 'done', - already_current: 'done', - cancelled: 'skipped', - failed: 'failed', - unsupported: 'skipped', - stale: 'working', - } - return SIMPLIFIED[status] ?? status + return cloudStore.translateAnalysisStatus(status) +} + +/** + * D-06: Determine the analysis action kind for a cloud file row. + * Returns 'analyze' | 'reanalyze' | 'retry' | null based on analysis_status. + * + * - null/pending/none → 'analyze' (not yet analyzed) + * - indexed/current/stale → 'reanalyze' (analyzed; re-analyze available) + * - failed/partial → 'retry' (failed; retry available) + * - unsupported → null (analysis not supported — no action rendered) + */ +function cloudRowActionKind(file) { + const status = file.analysis_status + if (!status || status === 'pending' || status === 'none') return 'analyze' + if (status === 'indexed' || status === 'already_current' || status === 'stale' || status === 'current') return 'reanalyze' + if (status === 'failed' || status === 'partial') return 'retry' + if (status === 'unsupported') return null + // Default: show analyze for unknown states + return 'analyze' +} + +/** + * Phase 14.1 / D-06: CSS class for inline analysis-status badge in cloud file rows. + * Maps simplified status to a color per the UI-SPEC: + * green — current/indexed + * amber — stale/skipped + * blue — working/waiting + * red — failed + */ +function cloudRowStatusBadgeClass(status) { + const simplified = translateStatus(status) + if (simplified === 'done') return 'bg-green-100 text-green-700' + if (simplified === 'skipped') return 'bg-amber-100 text-amber-700' + if (simplified === 'working' || simplified === 'waiting') return 'bg-blue-100 text-blue-700' + if (simplified === 'failed') return 'bg-red-100 text-red-700' + return 'bg-gray-100 text-gray-500' +} + +/** + * Phase 14.1 / D-06: Human-readable status label for inline cloud file row badge. + * Stale shows "stale" (not "working") in the row — distinct from queue-item status. + */ +function cloudRowStatusLabel(status) { + if (status === 'stale') return 'stale' + if (status === 'indexed' || status === 'already_current' || status === 'current') return 'indexed' + if (status === 'failed') return 'failed' + if (status === 'unsupported') return 'unsupported' + if (status === 'pending' || !status) return 'pending' + return translateStatus(status) } /** Toggle a file in the internal selection set (cloud mode only). */