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 } } 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) /** * 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) const state = result?.state ?? '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 } } 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, } })