feat(14.1-03): cloud-file-detail route + CloudDetailView + getCloudItemDetail + force wiring
- Add named route 'cloud-file-detail' at /cloud/:connectionId/item/:itemId(.*)
to frontend/src/router/index.js; placed before cloud-folder to prevent wildcard
capture; itemId is opaque (T-14.1-08)
- Add getCloudItemDetail(connectionId, itemId) to frontend/src/api/cloud.js
calling GET /api/cloud/connections/{id}/items/{encodeURIComponent(itemId)}/detail
(T-14.1-03/T-14.1-06: zero bytes, credential-free schema)
- Create frontend/src/views/CloudDetailView.vue as thin data-provider feeding
DocumentDetailSurface with CloudItem detail data + event handlers
- Wire force=true for re-analyze confirmation (D-11, Copywriting Contract modal)
- Unsupported preview shows reason + keeps Download active, no auto-download (D-14)
- Add fetchCloudItemDetail action + force param to enqueueAnalysis in store
(cloudConnections.js imports cloud.js barrel for getCloudItemDetail)
- translateAnalysisStatus remains single source; CloudDetailView calls store
translator, never defines its own
- All 8 CloudDetailParity.test.js tests now pass (route, params, Re-classify gone)
This commit is contained in:
@@ -353,6 +353,25 @@ export function retryAnalysisItem(jobId, itemId) {
|
||||
return jsonRequest(`/api/cloud/analysis/jobs/${jobId}/items/${itemId}/retry`, 'POST', {})
|
||||
}
|
||||
|
||||
// ── Phase 14.1: Cloud item detail ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch owner-scoped cloud item detail: extracted text, topics, analysis status,
|
||||
* provider metadata, and action capabilities.
|
||||
*
|
||||
* T-14.1-03: Response excludes credentials_enc, object_key, version_key, and
|
||||
* raw provider URLs — enforced by the CloudItemDetailOut schema allowlist.
|
||||
* T-14.1-06: Zero bytes downloaded — metadata only (no hydrate_and_cache_bytes).
|
||||
* D-05: Supports extracted text, topics, stale/current/pending state display.
|
||||
*
|
||||
* @param {string} connectionId - Connection UUID
|
||||
* @param {string} itemId - Provider item ID (opaque — must be encodeURIComponent'd)
|
||||
* @returns {Promise<CloudItemDetailOut>}
|
||||
*/
|
||||
export function getCloudItemDetail(connectionId, itemId) {
|
||||
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/detail`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user cache status and analysis settings.
|
||||
*
|
||||
|
||||
@@ -61,6 +61,17 @@ const routes = [
|
||||
component: () => import('../views/CloudStorageView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// cloud-file-detail must be declared BEFORE cloud-folder to avoid the
|
||||
// /:folderId(.*) wildcard consuming the /item/ segment.
|
||||
// The distinct /item/ segment disambiguates from folder navigation paths.
|
||||
// T-14.1-08: itemId is opaque — Vue Router handles URI encoding; frontend
|
||||
// code must not split or infer structure from provider IDs.
|
||||
path: '/cloud/:connectionId/item/:itemId(.*)',
|
||||
name: 'cloud-file-detail',
|
||||
component: () => import('../views/CloudDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/cloud/:connectionId/:folderId(.*)',
|
||||
name: 'cloud-folder',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from '../api/client.js'
|
||||
import * as cloudApi from '../api/cloud.js'
|
||||
|
||||
/**
|
||||
* Session-storage key for last visited folder per connection.
|
||||
@@ -436,15 +437,20 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
|
||||
/**
|
||||
* ANALYZE-01: Enqueue an analysis job and track it in store state.
|
||||
*
|
||||
* D-11: When force=true, bypasses already_current check for supported items,
|
||||
* allowing re-analysis of files that have not changed since last indexed.
|
||||
*
|
||||
* @param {string} connectionId - Connection UUID
|
||||
* @param {{ scope: string, provider_item_ids?: string[], recursive?: boolean,
|
||||
* failure_behavior?: string }} params
|
||||
* failure_behavior?: string, force?: boolean }} params
|
||||
* @returns {Promise<object>} AnalysisEnqueueOut
|
||||
*/
|
||||
async function enqueueAnalysis(connectionId, params) {
|
||||
const result = await api.enqueueAnalysis(connectionId, {
|
||||
...params,
|
||||
failure_behavior: params.failure_behavior ?? failureBehavior.value,
|
||||
// D-11: forward force flag to backend — undefined means default (false)
|
||||
...(params.force !== undefined ? { force: params.force } : {}),
|
||||
})
|
||||
// Initialize the active job in memory (no credentials stored)
|
||||
activeAnalysisJob.value = {
|
||||
@@ -593,6 +599,23 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 14.1 / D-01: Fetch owner-scoped cloud item detail.
|
||||
*
|
||||
* Returns CloudItemDetailOut: extracted_text, topics, analysis_status,
|
||||
* provider metadata, capabilities, unsupported_analysis_reason, is_stale.
|
||||
* T-14.1-03: Response excludes credentials_enc, object_key, version_key,
|
||||
* raw provider URLs (enforced by backend schema allowlist).
|
||||
* T-14.1-06: Zero bytes downloaded — metadata only.
|
||||
*
|
||||
* @param {string} connectionId - Connection UUID
|
||||
* @param {string} itemId - Provider item ID (opaque)
|
||||
* @returns {Promise<object>} CloudItemDetailOut
|
||||
*/
|
||||
async function fetchCloudItemDetail(connectionId, itemId) {
|
||||
return cloudApi.getCloudItemDetail(connectionId, itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* CACHE-04: Update user cache settings via the API.
|
||||
*
|
||||
@@ -689,6 +712,8 @@ export const useCloudConnectionsStore = defineStore('cloudConnections', () => {
|
||||
retryItem,
|
||||
skipItem,
|
||||
cancelItem,
|
||||
// Phase 14.1: Cloud item detail
|
||||
fetchCloudItemDetail,
|
||||
// User preferences and cache settings
|
||||
setFailureBehavior,
|
||||
setDetailedAnalysisProgress,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="p-8 max-w-4xl mx-auto text-gray-400 text-sm">Loading…</div>
|
||||
<div v-else-if="error" class="p-8 max-w-4xl mx-auto text-red-500 text-sm">{{ error }}</div>
|
||||
|
||||
<DocumentDetailSurface
|
||||
v-else-if="item"
|
||||
:title="item.name"
|
||||
:metadata-line="metadataLine"
|
||||
:source="sourceProps"
|
||||
:topics="item.topics || []"
|
||||
:analysis-status="item.analysis_status"
|
||||
:extracted-text="item.extracted_text"
|
||||
:preview-state="previewStateProps"
|
||||
:download-state="downloadStateProps"
|
||||
:analysis-action="analysisActionProps"
|
||||
@back="$router.back()"
|
||||
@preview="handlePreview"
|
||||
@download="handleDownload"
|
||||
@analyze="handleAnalyze"
|
||||
@reanalyze="handleReanalyze"
|
||||
@retry-analysis="handleRetry"
|
||||
>
|
||||
<!-- Preview unavailable: keep Download active, never auto-download (D-14) -->
|
||||
<template #secondary-controls>
|
||||
<div
|
||||
v-if="previewUnavailableReason"
|
||||
class="mt-4 px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600"
|
||||
>
|
||||
<span class="font-medium">Preview unavailable</span>
|
||||
<span class="ml-1">{{ previewUnavailableReason }}</span>
|
||||
<span v-if="downloadStateProps && downloadStateProps.supported" class="ml-2 text-gray-400">
|
||||
Use <strong>Download</strong> to access the file.
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</DocumentDetailSurface>
|
||||
|
||||
<!-- Re-analyze confirmation modal (D-11) -->
|
||||
<div
|
||||
v-if="showReanalyzeConfirm"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
@click.self="showReanalyzeConfirm = false"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reanalyze-modal-title"
|
||||
class="bg-white rounded-2xl shadow-xl p-6 max-w-sm w-full mx-4"
|
||||
>
|
||||
<h2 id="reanalyze-modal-title" class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Re-analyze this file?
|
||||
</h2>
|
||||
<!-- Copywriting Contract destructive confirmation copy -->
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Existing extracted text and topics stay visible until the new analysis finishes.
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
@click="showReanalyzeConfirm = false"
|
||||
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmReanalyze"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white text-sm px-4 py-2 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
||||
>
|
||||
Re-analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
|
||||
import * as cloudApi from '../api/cloud.js'
|
||||
import DocumentDetailSurface from '../components/storage/DocumentDetailSurface.vue'
|
||||
import { formatDate, formatSize, providerLabel } from '../utils/formatters.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
|
||||
// Route params — opaque; never split or decode provider ID structure (T-14.1-08)
|
||||
const connectionId = computed(() => route.params.connectionId)
|
||||
const itemId = computed(() => route.params.itemId)
|
||||
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const analyzing = ref(false)
|
||||
const showReanalyzeConfirm = ref(false)
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Failed to load cloud file details.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ── Computed props for DocumentDetailSurface ──────────────────────────────────
|
||||
|
||||
/** Formatted metadata line: modified date · size · content type */
|
||||
const metadataLine = computed(() => {
|
||||
if (!item.value) return ''
|
||||
const parts = []
|
||||
if (item.value.modified_at) parts.push(formatDate(item.value.modified_at))
|
||||
if (item.value.size) parts.push(formatSize(item.value.size))
|
||||
if (item.value.content_type) parts.push(item.value.content_type)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
/** Cloud source metadata for the surface (provider chip + location + stale state) */
|
||||
const sourceProps = computed(() => {
|
||||
if (!item.value) return null
|
||||
return {
|
||||
provider: item.value.provider,
|
||||
location: item.value.location || null,
|
||||
// D-07: stale flag drives amber badge and Re-analyze promotion
|
||||
isStale: item.value.is_stale || item.value.analysis_status === 'stale',
|
||||
// D-06: single status translator — never translate independently here
|
||||
statusLabel: cloudStore.translateAnalysisStatus(item.value.analysis_status),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Preview state derived from analysis_status and unsupported_analysis_reason.
|
||||
* D-14: Show Preview unavailable with reason; keep Download active; NO auto-download.
|
||||
*
|
||||
* Cloud capabilities dict is intentionally empty (capabilities={}; Plan 02 decision
|
||||
* — live capability resolution requires credentials). We derive preview availability
|
||||
* from content_type: PDFs and images are previewable in-app; others are not.
|
||||
*/
|
||||
const previewStateProps = computed(() => {
|
||||
if (!item.value) return null
|
||||
const ct = item.value.content_type || ''
|
||||
const isPreviewable = ct === 'application/pdf' || ct.startsWith('image/')
|
||||
if (isPreviewable) {
|
||||
return { supported: true, openLabel: 'Preview file' }
|
||||
}
|
||||
// Not previewable in-app — surface reason and keep Download active
|
||||
return {
|
||||
supported: false,
|
||||
reason: `${providerLabel(item.value.provider)} files of type "${ct || 'this format'}" cannot be previewed in-app.`,
|
||||
}
|
||||
})
|
||||
|
||||
/** Download state — always supported for cloud files (authorized download endpoint) */
|
||||
const downloadStateProps = computed(() => {
|
||||
if (!item.value) return null
|
||||
return { supported: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Analysis action kind derived from analysis_status per Detail State Contract:
|
||||
* - 'analyze' when status is pending/none/null (not yet analyzed)
|
||||
* - 'reanalyze' when status is indexed/current/stale (has usable data)
|
||||
* - 'retry' when status is failed/partial (failed work)
|
||||
* - null when unsupported
|
||||
*/
|
||||
const analysisActionProps = computed(() => {
|
||||
if (!item.value) return null
|
||||
// Unsupported — do not show an analysis action
|
||||
if (item.value.unsupported_analysis_reason) return null
|
||||
const status = item.value.analysis_status
|
||||
let kind
|
||||
if (!status || status === 'pending' || status === 'none') {
|
||||
kind = 'analyze'
|
||||
} else if (status === 'failed' || status === 'partial') {
|
||||
kind = 'retry'
|
||||
} else {
|
||||
// indexed, already_current, stale, queued, etc. → Re-analyze
|
||||
kind = 'reanalyze'
|
||||
}
|
||||
return { kind, busy: analyzing.value }
|
||||
})
|
||||
|
||||
/**
|
||||
* Preview unavailable reason for secondary-controls slot.
|
||||
* Only shown when preview is not supported (D-14).
|
||||
*/
|
||||
const previewUnavailableReason = computed(() => {
|
||||
const ps = previewStateProps.value
|
||||
if (!ps || ps.supported) return null
|
||||
return ps.reason || 'This file format cannot be previewed in-app.'
|
||||
})
|
||||
|
||||
// ── Event handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Preview: open in-app preview via authorized endpoint.
|
||||
* D-14: Unsupported preview does NOT auto-download — user sees reason above.
|
||||
* Only called when previewState.supported is true.
|
||||
*/
|
||||
async function handlePreview() {
|
||||
try {
|
||||
const result = await cloudApi.previewCloudFile(connectionId.value, itemId.value)
|
||||
if (result.kind === 'unsupported_preview') {
|
||||
// Surface reason and keep Download active — never auto-download (D-14)
|
||||
if (item.value) {
|
||||
item.value = {
|
||||
...item.value,
|
||||
_preview_unavailable_reason: result.reason || 'Unsupported format',
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.url) {
|
||||
window.open(result.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (e) {
|
||||
// Surface error inline — keep Download active
|
||||
if (item.value) {
|
||||
item.value = {
|
||||
...item.value,
|
||||
_preview_unavailable_reason: e?.message || 'Preview failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download: explicit authorized download via DocuVault endpoint.
|
||||
* D-02: Never exposes provider URLs.
|
||||
*/
|
||||
async function handleDownload() {
|
||||
try {
|
||||
const result = await cloudApi.downloadCloudFile(connectionId.value, itemId.value)
|
||||
if (result.url) {
|
||||
window.open(result.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (e) {
|
||||
// Error handled inline; Download stays available for retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze: enqueue first analysis job (no force — file not yet analyzed).
|
||||
* ANALYZE-01: scope=file + opaque provider_item_id.
|
||||
*/
|
||||
async function handleAnalyze() {
|
||||
analyzing.value = true
|
||||
try {
|
||||
await cloudStore.enqueueAnalysis(connectionId.value, {
|
||||
scope: 'file',
|
||||
provider_item_ids: [itemId.value],
|
||||
})
|
||||
// Refresh detail to reflect new status
|
||||
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
|
||||
} catch (e) {
|
||||
// Error surfaced via toast or inline — analyzing flag resets below
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-analyze: for already-indexed/current/stale files.
|
||||
* D-11: If already-current, show confirmation before forcing fresh extraction.
|
||||
*/
|
||||
async function handleReanalyze() {
|
||||
// Show confirmation dialog — user decides whether to force re-analysis
|
||||
showReanalyzeConfirm.value = true
|
||||
}
|
||||
|
||||
/** Confirmed re-analyze with force=true (D-11). */
|
||||
async function confirmReanalyze() {
|
||||
showReanalyzeConfirm.value = false
|
||||
analyzing.value = true
|
||||
try {
|
||||
await cloudStore.enqueueAnalysis(connectionId.value, {
|
||||
scope: 'file',
|
||||
provider_item_ids: [itemId.value],
|
||||
force: true,
|
||||
})
|
||||
// Refresh detail to reflect queued status
|
||||
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
|
||||
} catch (e) {
|
||||
// Error surfaced via toast
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry: for failed analysis items.
|
||||
* D-12: Uses single-item retry job path (retryItem or new job via retryAnalysisItem).
|
||||
*/
|
||||
async function handleRetry() {
|
||||
if (!item.value) return
|
||||
analyzing.value = true
|
||||
try {
|
||||
// Use the single-item retry endpoint added in Plan 02 (D-12)
|
||||
// The item's cloud_item_id (DocuVault UUID) is used for stable identity
|
||||
if (item.value.id) {
|
||||
await cloudStore.enqueueAnalysis(connectionId.value, {
|
||||
scope: 'file',
|
||||
provider_item_ids: [itemId.value],
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
item.value = await cloudApi.getCloudItemDetail(connectionId.value, itemId.value)
|
||||
} catch (e) {
|
||||
// Error surfaced via toast
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user