import { defineStore } from 'pinia' import { ref, computed } from 'vue' import * as api from '../api/client.js' /** * Session-storage key for last visited folder per connection. * Only folder navigation state is stored — never tokens or credentials. */ function folderSessionKey(connectionId) { return `docuvault:cloud:folder:${connectionId}` } /** * Valid health state values used by both browser compact status and Settings diagnostics. * Translates server vocabulary to UI-actionable states (D-12). * * healthy — provider reachable and credentials valid * requires_reauth — credentials expired or revoked; reconnect required (D-14/D-15) * degraded — transient outage; credentials preserved, retry/reconnect allowed (D-15) * unknown — not yet tested or health state unavailable */ const VALID_HEALTH_STATES = new Set(['healthy', 'requires_reauth', 'degraded', 'unknown']) export function saveLastFolder(connectionId, folderId) { try { if (folderId && folderId !== 'root') { sessionStorage.setItem(folderSessionKey(connectionId), folderId) } else { sessionStorage.removeItem(folderSessionKey(connectionId)) } } catch { // sessionStorage unavailable — ignore } } export function loadLastFolder(connectionId) { try { return sessionStorage.getItem(folderSessionKey(connectionId)) || null } catch { return null } } // ── Phase 14: Analysis status translation ──────────────────────────────────── /** * D-06: Single source for internal status → simplified UI label mapping. * Components never translate independently — they call translateAnalysisStatus. */ const SIMPLIFIED_STATUS_MAP = { queued: 'waiting', downloading: 'working', extracting: 'working', classifying: 'working', indexed: 'done', already_current: 'done', cancelled: 'skipped', failed: 'failed', unsupported: 'skipped', stale: 'working', } const DETAILED_STATUS_MAP = { queued: 'queued', downloading: 'downloading', extracting: 'extracting', classifying: 'classifying', indexed: 'indexed', already_current: 'indexed', cancelled: 'cancelled', failed: 'failed', unsupported: 'unsupported', stale: 'stale', } export const useCloudConnectionsStore = defineStore('cloudConnections', () => { const connections = ref([]) const loading = ref(false) const error = ref(null) // Active browse state (populated by CloudFolderView) const selectedConnectionId = ref(null) const browseItems = ref([]) const browseLoading = ref(false) const browseError = ref(null) /** Capabilities for the currently selected connection */ const capabilities = ref(null) /** 'refreshing' | 'fresh' | 'stale' | null */ const folderFreshness = ref(null) const lastRefreshedAt = ref(null) /** 'cached' | 'cloud_only' | null */ const byteAvailability = ref(null) // ── Phase 14: Analysis state ───────────────────────────────────────────────── /** * Active analysis job for the current connection session. * Stored in Pinia memory only — never persisted to localStorage/sessionStorage. * T-14-02: No credentials or object_key fields stored here. */ const activeAnalysisJob = ref(null) /** * Analysis queue items from the active job for display in StorageBrowser. * Derived from activeAnalysisJob.items when a job is active. */ const analysisQueue = computed(() => { if (!activeAnalysisJob.value) return [] const items = activeAnalysisJob.value.items ?? [] return items.map(item => ({ job_id: activeAnalysisJob.value.job_id, item_id: item.item_id, name: item.name, status: item.status, ui_status: SIMPLIFIED_STATUS_MAP[item.status] ?? item.status, progress: item.progress ?? 0, error: item.error ?? null, })) }) /** * D-11: Failure behavior for analysis jobs. * 'pause_batch' — pause the whole batch on any item failure (default) * 'continue_item' — skip failed items and continue */ const failureBehavior = ref('pause_batch') /** * D-06: When true, per-item status shows detailed internal stages * (downloading/extracting/classifying). When false (default), shows simplified * labels (waiting/working/done/skipped/failed). */ const detailedAnalysisProgress = ref(false) /** * CACHE-04: User-configured cloud cache limit in bytes. * Default: 500 MB (500 * 1024 * 1024). * Hydrated from server via loadCacheSettings — in-memory only, not persisted. */ const cacheLimit = ref(500 * 1024 * 1024) /** * Server-authoritative tier cap for the cache limit in bytes. * Loaded from GET /api/cloud/analysis/cache (tier_cap_bytes field). * null until loadCacheSettings has run. */ const tierCapBytes = ref(null) /** * Current aggregate cache usage from the server. * Loaded from GET /api/cloud/analysis/cache. * { entry_count: number, total_bytes: number } — no object_key or credentials. */ const cacheUsage = ref(null) /** * Whether the last loadCacheSettings call is in flight. */ const cacheSettingsLoading = ref(false) /** * Validation/network error from the last settings call, or null. */ const cacheSettingsError = ref(null) /** * D-12: Connection health state map — single source for both browser compact status * and Settings full diagnostics. * * Key: connection UUID * Value: { state: 'healthy'|'requires_reauth'|'degraded'|'unknown', error_code, error_message, checked_at } * * Neither the browser nor Settings translate server states independently — * they read from this map only. */ const connectionHealth = ref({}) /** * D-13 / D-14: Set of connection IDs that need a health retest. * CloudFolderView reads this and performs the test after navigation settles. * Cleared after the test is performed. */ const pendingHealthRetest = ref(new Set()) /** * D-14: Flag set during reconnect so CloudFolderView refreshes the current folder * after a successful reconnect without clearing cached browse items first. */ const reconnectRefreshPending = ref(false) const selectedConnection = computed(() => connections.value.find(c => c.id === selectedConnectionId.value) ?? null ) async function fetchConnections() { loading.value = true error.value = null try { const data = await api.listCloudConnections() connections.value = data.items ?? [] } catch (e) { error.value = e.message || 'Failed to load cloud connections' } finally { loading.value = false } } async function disconnect(id) { try { await api.disconnectCloud(id) connections.value = connections.value.filter(c => c.id !== id) if (selectedConnectionId.value === id) { selectedConnectionId.value = null } } catch (e) { throw e } } async function disconnectAll() { const ids = connections.value.map(c => c.id) for (const id of ids) await disconnect(id) connections.value = [] } /** * Rename a connection's display_name via the API. * Only updates the single returned connection in state. */ async function rename(id, displayName) { const trimmed = displayName?.trim() if (!trimmed) throw new Error('Display name cannot be empty') const updated = await api.renameCloudConnection(id, trimmed) const idx = connections.value.findIndex(c => c.id === id) if (idx !== -1) { connections.value[idx] = { ...connections.value[idx], ...updated } } return updated } /** * Default display_name for a connection. * If another connection shares the same provider, append a 4-char UUID suffix. */ function defaultDisplayName(connection) { const providerLabel = { google_drive: 'Google Drive', onedrive: 'OneDrive', nextcloud: 'Nextcloud', webdav: 'WebDAV', }[connection.provider] ?? connection.provider const siblings = connections.value.filter( c => c.provider === connection.provider && c.id !== connection.id ) if (siblings.length > 0) { return `${providerLabel} (${connection.id.slice(0, 4)})` } return providerLabel } function selectConnection(id) { selectedConnectionId.value = id browseItems.value = [] browseError.value = null capabilities.value = null folderFreshness.value = null lastRefreshedAt.value = null byteAvailability.value = null } function setBrowseState({ items, caps, freshness, refreshedAt, byteAvail, err }) { if (err !== undefined) browseError.value = err if (items !== undefined) browseItems.value = items if (caps !== undefined) capabilities.value = caps if (freshness !== undefined) folderFreshness.value = freshness if (refreshedAt !== undefined) lastRefreshedAt.value = refreshedAt if (byteAvail !== undefined) byteAvailability.value = byteAvail } /** * D-12: Set the health state for a specific connection. * Translates server health payload to the UI-actionable vocabulary. * Both browser compact status and Settings diagnostics consume this. * * @param {string} id — connection UUID * @param {{ state: string, error_code?: string, error_message?: string, checked_at?: string }} healthData */ function setConnectionHealth(id, healthData) { const state = VALID_HEALTH_STATES.has(healthData?.state) ? healthData.state : 'unknown' connectionHealth.value = { ...connectionHealth.value, [id]: { state, error_code: healthData?.error_code ?? null, error_message: healthData?.error_message ?? null, checked_at: healthData?.checked_at ?? null, }, } } /** * D-13: Mark a connection as requiring a health retest after a credential failure. * This is the complement of the no-probe-on-navigation rule: * probes happen only after failures (here), not on ordinary navigation. * * @param {string} id — connection UUID * @param {{ state: string, error_code?: string }} failureData */ function handleHealthFailure(id, failureData) { // Record the failure state in the health map setConnectionHealth(id, failureData) // Schedule a retest (D-13: auto-test after credential failure) pendingHealthRetest.value = new Set([...pendingHealthRetest.value, id]) } /** * D-14: Mark that a reconnect just completed and the current folder should refresh. * Does NOT clear browse items — they stay visible as stale while the refresh runs. * * @param {string} id — connection UUID */ function markReconnectRefreshPending(id) { reconnectRefreshPending.value = true // Keep browse items visible but downgrade freshness to stale (D-14) if (folderFreshness.value !== null) { folderFreshness.value = 'stale' } } /** * D-14: Enter reconnecting state — preserve cached items as stale. * Does NOT call selectConnection (which clears items). * * @param {string} id — connection UUID */ function markReconnecting(id) { // D-14: cached metadata remains visible; downgrade freshness only if (folderFreshness.value !== null) { folderFreshness.value = 'stale' } // Set health to unknown while reconnect is in flight connectionHealth.value = { ...connectionHealth.value, [id]: { state: 'unknown', error_code: null, error_message: null, checked_at: null }, } } /** * D-13: Explicitly test the health of a connection. * Only called after connect/reconnect/failure — never as a navigation side effect. * * @param {string} id — connection UUID * @returns {Promise<{ state: string }>} */ async function testConnection(id) { try { const result = await api.testCloudConnection(id) // WR-06: server returns { status: ... }, not { state: ... }. Translate at the // store boundary — internal vocabulary uses `state` but the API field is `status`. const state = result?.status ?? 'unknown' setConnectionHealth(id, { state, ...result }) // Clear pending retest flag for this connection const next = new Set(pendingHealthRetest.value) next.delete(id) pendingHealthRetest.value = next return result } catch (e) { const failState = { state: 'degraded', error_code: 'probe_error', error_message: e?.message ?? 'Health check failed' } setConnectionHealth(id, failState) return failState } } /** * D-13: Store-level reconnect handler. * Marks reconnecting state (cached items preserved), then fetches updated connections. * * @param {string} id — connection UUID */ async function reconnect(id) { markReconnecting(id) // Re-fetch connection list to pick up updated status await fetchConnections() markReconnectRefreshPending(id) } /** * D-12 / D-13: Connections with null or absent health are candidates for an initial test. * The store exposes this as a computed so the view can decide when to run tests. */ const connectionsNeedingHealthCheck = computed(() => connections.value.filter(c => { const h = connectionHealth.value[c.id] return !h || h.state === undefined || h.state === 'unknown' }) ) /** * D-12: Retrieve the health state for a specific connection. * Returns { state: 'unknown', ... } if no health data is available. * * @param {string} id — connection UUID * @returns {{ state: string, error_code: string|null, error_message: string|null, checked_at: string|null }} */ function getConnectionHealth(id) { return connectionHealth.value[id] ?? { state: 'unknown', error_code: null, error_message: null, checked_at: null } } // ── Phase 14: Analysis actions ───────────────────────────────────────────── /** * D-06: Translate an internal analysis status to a UI label. * Single translation source — components must not translate independently. * * @param {string} status - Internal status (queued, downloading, extracting, etc.) * @param {{ detailed?: boolean }} [opts] * @returns {string} - UI label */ function translateAnalysisStatus(status, opts = {}) { const map = opts.detailed ? DETAILED_STATUS_MAP : SIMPLIFIED_STATUS_MAP return map[status] ?? status } /** * ANALYZE-01 / ANALYZE-03: Request a scope estimate for an analysis job. * No provider bytes are downloaded (T-14-04). * * @param {string} connectionId - Connection UUID * @param {{ scope: string, provider_item_ids?: string[], recursive?: boolean }} params * @returns {Promise} AnalysisEstimateOut */ async function requestEstimate(connectionId, params) { return api.estimateAnalysis(connectionId, params) } /** * ANALYZE-01: Enqueue an analysis job and track it in store state. * * @param {string} connectionId - Connection UUID * @param {{ scope: string, provider_item_ids?: string[], recursive?: boolean, * failure_behavior?: string }} params * @returns {Promise} AnalysisEnqueueOut */ async function enqueueAnalysis(connectionId, params) { const result = await api.enqueueAnalysis(connectionId, { ...params, failure_behavior: params.failure_behavior ?? failureBehavior.value, }) // Initialize the active job in memory (no credentials stored) activeAnalysisJob.value = { job_id: result.job_id, connection_id: connectionId, status: result.status, total_count: result.total_count, queued_count: result.queued_count, already_current_count: result.already_current_count, unsupported_count: result.unsupported_count, items: [], } return result } /** * ANALYZE-04: Fetch and update status of the active analysis job. * T-14-02: Response must never include credentials or object_key. * * @param {string} jobId - Job UUID * @returns {Promise} AnalysisJobOut */ async function fetchJobStatus(jobId) { const result = await api.getAnalysisJobStatus(jobId) // Store job state in Pinia memory — no credentials/object_key fields exposed activeAnalysisJob.value = { job_id: result.job_id ?? jobId, connection_id: result.connection_id ?? activeAnalysisJob.value?.connection_id, status: result.status, total_count: result.total_count ?? 0, queued_count: result.queued_count ?? 0, already_current_count: result.already_current_count ?? 0, unsupported_count: result.unsupported_count ?? 0, items: (result.items ?? []).map(item => ({ item_id: item.item_id, cloud_item_id: item.cloud_item_id, name: item.name, status: item.status, error: item.error ?? null, })), } return result } /** * ANALYZE-05: Cancel the entire analysis job batch. * * @param {string} jobId - Job UUID */ async function cancelJob(jobId) { const result = await api.cancelAnalysisJob(jobId) if (activeAnalysisJob.value?.job_id === jobId) { activeAnalysisJob.value = { ...activeAnalysisJob.value, status: 'cancelled' } } return result } /** * ANALYZE-05: Retry a failed analysis job item. * * @param {string} jobId - Job UUID * @param {string} itemId - Item UUID */ async function retryItem(jobId, itemId) { return api.retryAnalysisItem(jobId, itemId) } /** * ANALYZE-05: Skip a queued or failed analysis job item. * * @param {string} jobId - Job UUID * @param {string} itemId - Item UUID */ async function skipItem(jobId, itemId) { return api.skipAnalysisItem(jobId, itemId) } /** * ANALYZE-05: Cancel a single analysis job item. * * @param {string} jobId - Job UUID * @param {string} itemId - Item UUID */ async function cancelItem(jobId, itemId) { return api.cancelAnalysisItem(jobId, itemId) } /** * D-11: Set the failure behavior for analysis jobs. * * @param {'pause_batch'|'continue_item'} behavior */ function setFailureBehavior(behavior) { failureBehavior.value = behavior } /** * D-06: Toggle detailed analysis progress mode. * * @param {boolean} enabled */ function setDetailedAnalysisProgress(enabled) { detailedAnalysisProgress.value = !!enabled } /** * CACHE-04: Load cache status and analysis settings from the server. * * Hydrates cacheLimit, tierCapBytes, cacheUsage, detailedAnalysisProgress, * and failureBehavior from the authoritative backend state. * * Errors do not clear existing in-memory settings — cacheSettingsError is * set so the UI can surface a controlled message. */ async function loadCacheSettings() { cacheSettingsLoading.value = true cacheSettingsError.value = null try { const result = await api.getCacheSettings() // Hydrate server-authoritative values into local state if (result?.cache_limit_bytes !== undefined) { cacheLimit.value = result.cache_limit_bytes } if (result?.tier_cap_bytes !== undefined) { tierCapBytes.value = result.tier_cap_bytes } if (result?.entry_count !== undefined || result?.total_bytes !== undefined) { cacheUsage.value = { entry_count: result.entry_count ?? 0, total_bytes: result.total_bytes ?? 0, } } if (result?.analysis_progress_detail !== undefined) { detailedAnalysisProgress.value = !!result.analysis_progress_detail } if (result?.analysis_failure_behavior !== undefined) { failureBehavior.value = result.analysis_failure_behavior } return result } catch (e) { // Do not clear existing settings on error — surface a controlled message cacheSettingsError.value = e?.message ?? 'Failed to load cache settings' return null } finally { cacheSettingsLoading.value = false } } /** * CACHE-04: Update user cache settings via the API. * * Merges server response back into local state after a successful update. * Validation errors surface as a thrown error (caller responsible for display). * * @param {{ cloud_cache_limit_bytes?: number, analysis_progress_detail?: boolean, * analysis_failure_behavior?: string }} params */ async function updateCacheSettings(params) { cacheSettingsError.value = null try { const result = await api.updateCacheSettings(params) // Merge server response into local state if (result?.cache_limit_bytes !== undefined) { cacheLimit.value = result.cache_limit_bytes } if (result?.cloud_cache_limit_bytes !== undefined) { cacheLimit.value = result.cloud_cache_limit_bytes } if (result?.tier_cap_bytes !== undefined) { tierCapBytes.value = result.tier_cap_bytes } if (result?.entry_count !== undefined || result?.total_bytes !== undefined) { cacheUsage.value = { entry_count: result.entry_count ?? 0, total_bytes: result.total_bytes ?? 0, } } if (result?.analysis_progress_detail !== undefined) { detailedAnalysisProgress.value = !!result.analysis_progress_detail } if (result?.analysis_failure_behavior !== undefined) { failureBehavior.value = result.analysis_failure_behavior } return result } catch (e) { // Surface validation error without clearing existing settings cacheSettingsError.value = e?.message ?? 'Failed to update cache settings' throw e } } return { connections, loading, error, selectedConnectionId, selectedConnection, browseItems, browseLoading, browseError, capabilities, folderFreshness, lastRefreshedAt, byteAvailability, // D-12: health state map — single source for browser and Settings connectionHealth, // D-13: pending health retests (after credential failures) pendingHealthRetest, // D-14: reconnect refresh flag reconnectRefreshPending, // D-12: connections needing initial health check connectionsNeedingHealthCheck, fetchConnections, disconnect, disconnectAll, rename, defaultDisplayName, selectConnection, setBrowseState, // D-12/D-13: health management setConnectionHealth, getConnectionHealth, handleHealthFailure, markReconnectRefreshPending, markReconnecting, testConnection, reconnect, // ── Phase 14: Analysis state and actions ────────────────────────────────── // State (Pinia memory only — no credentials stored) activeAnalysisJob, analysisQueue, failureBehavior, detailedAnalysisProgress, cacheLimit, // D-06: single status translation source translateAnalysisStatus, // Analysis job lifecycle requestEstimate, enqueueAnalysis, fetchJobStatus, cancelJob, retryItem, skipItem, cancelItem, // User preferences and cache settings setFailureBehavior, setDetailedAnalysisProgress, loadCacheSettings, updateCacheSettings, // Server-authoritative cache state tierCapBytes, cacheUsage, cacheSettingsLoading, cacheSettingsError, } })