From 3d63dc19879a51b038cc385fd232aa147e051ae7 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Jul 2026 19:08:22 +0200 Subject: [PATCH] =?UTF-8?q?sse=20impl=C3=A9mentaton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mobile/App.tsx | 13 ++- mobile/api/client.ts | 62 ++++++++++++ mobile/app/index.tsx | 37 +++++-- mobile/components/SettingsModal.tsx | 86 +++++++++++----- mobile/components/UploadModal.tsx | 4 +- mobile/constants/api.ts | 1 + mobile/contexts/SseContext.tsx | 84 ++++++++++++++++ mobile/hooks/useDeviceFiles.ts | 150 +++++++++++++++++++++++++++- mobile/hooks/useLocalFiles.ts | 5 +- mobile/hooks/usePollOcr.ts | 90 +++++++++++------ mobile/hooks/useUpload.ts | 17 +--- mobile/services/safDirectory.ts | 66 +++++++++++- mobile/types/index.ts | 5 + 13 files changed, 533 insertions(+), 87 deletions(-) create mode 100644 mobile/contexts/SseContext.tsx diff --git a/mobile/App.tsx b/mobile/App.tsx index 1396fe1..a3a9cc4 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { DeviceProvider, useDevice } from './contexts/DeviceContext'; +import { SseProvider } from './contexts/SseContext'; +import { SseOcrListener } from './hooks/usePollOcr'; import { LoginScreen } from './app/login'; import { RegisterScreen } from './app/register'; import { HomeScreen } from './app/index'; @@ -103,10 +105,13 @@ function AppNavigator() { function AppContent() { return ( - - - - + + + + + + + ); } diff --git a/mobile/api/client.ts b/mobile/api/client.ts index da78c89..0c23538 100644 --- a/mobile/api/client.ts +++ b/mobile/api/client.ts @@ -95,6 +95,68 @@ class ApiClient { await tokenStorage.deleteUser(); } + async subscribeEvents( + onEvent: (event: string, data: string) => void, + onError: (err: Error) => void, + ): Promise<() => void> { + const abortController = new AbortController(); + + const connect = async () => { + try { + const headers: Record = { + Accept: 'text/event-stream', + }; + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + + const response = await fetch(`${this.baseUrl}${ENDPOINTS.EVENTS}`, { + headers, + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`SSE connection failed: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('Streaming not supported'); + + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = ''; + + while (!abortController.signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7); + } else if (line.startsWith('data: ')) { + if (currentEvent) { + onEvent(currentEvent, line.slice(6)); + currentEvent = ''; + } + } + } + } + } catch (err) { + if (!abortController.signal.aborted) { + onError(err instanceof Error ? err : new Error(String(err))); + } + } + }; + + connect(); + + return () => abortController.abort(); + } + async get(endpoint: string): Promise { return this.request(endpoint); } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 44a3323..12e7a04 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -94,7 +94,7 @@ export function HomeScreen() { const insets = useSafeAreaInsets(); const [page, setPage] = useState(1); const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE); - const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles(); + const { hasPermission, requestPermission, pickDirectory, pickAndScanRecursive, folders, refreshFolders, discovered } = useLocalFiles(); const freeLocalSpace = useFreeLocalSpace(); const queryClient = useQueryClient(); const [searchQuery, setSearchQuery] = useState(''); @@ -336,6 +336,11 @@ export function HomeScreen() { await pickDirectory(); }, [pickDirectory]); + const handleAddFolderRecursive = useCallback(async () => { + setSettingsModalVisible(false); + await pickAndScanRecursive(); + }, [pickAndScanRecursive]); + const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => { safDirectory.updateSyncMode(folderId, mode); refreshFolders(); @@ -438,12 +443,29 @@ export function HomeScreen() { )} {files.length === 0 && !searchQuery && ( - - - - {folders.length > 0 ? 'Aucun fichier trouvé — re-sélectionner' : 'Scanner un dossier de votre appareil'} - - + + {folders.length > 0 ? ( + <> + + + {folders.length} dossier{folders.length > 1 ? 's' : ''} scanné{folders.length > 1 ? 's' : ''} + + + Tout scanner + + + ) : ( + <> + + + Scanner un dossier de votre appareil + + + Choisir + + + )} + )} @@ -647,6 +669,7 @@ export function HomeScreen() { onToggleVisibility={handleToggleFolderVisibility} onRemoveFolder={handleRemoveFolder} onAddFolder={handleAddFolder} + onAddFolderRecursive={handleAddFolderRecursive} onUpdateSyncMode={handleUpdateSyncMode} onUpdateSyncCellular={handleUpdateSyncCellular} globalSyncMode={globalSyncMode} diff --git a/mobile/components/SettingsModal.tsx b/mobile/components/SettingsModal.tsx index 0bd616c..ba055c3 100644 --- a/mobile/components/SettingsModal.tsx +++ b/mobile/components/SettingsModal.tsx @@ -9,7 +9,7 @@ import { Alert, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { StoredFolder, SyncMode, SyncGlobalMode } from '../services/safDirectory'; +import { StoredFolder, SyncMode, SyncGlobalMode, type FolderSource } from '../services/safDirectory'; type SettingsView = 'menu' | 'folders' | 'sync'; @@ -20,6 +20,7 @@ interface SettingsModalProps { onToggleVisibility: (folderId: string) => void; onRemoveFolder: (folderId: string) => void; onAddFolder: () => void; + onAddFolderRecursive?: () => void; onUpdateSyncMode: (folderId: string, mode: SyncMode) => void; onUpdateSyncCellular: (folderId: string, enabled: boolean) => void; globalSyncMode: SyncGlobalMode; @@ -47,6 +48,7 @@ export function SettingsModal({ onToggleVisibility, onRemoveFolder, onAddFolder, + onAddFolderRecursive, onUpdateSyncMode, onUpdateSyncCellular, globalSyncMode, @@ -108,6 +110,7 @@ export function SettingsModal({ onToggleVisibility={onToggleVisibility} onRemoveFolder={handleRemoveFolder} onAddFolder={onAddFolder} + onAddFolderRecursive={onAddFolderRecursive} /> )} @@ -163,18 +166,31 @@ function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void; ); } +function folderIcon(folder: StoredFolder): { name: keyof typeof MaterialIcons.glyphMap; color: string } { + switch (folder.source) { + case 'media-library': + return { name: 'photo-library', color: '#43A047' }; + case 'recursive': + return { name: 'subdirectory-arrow-right', color: '#8E24AA' }; + default: + return { name: 'folder', color: '#F57C00' }; + } +} + function FoldersView({ folders, onBack, onToggleVisibility, onRemoveFolder, onAddFolder, + onAddFolderRecursive, }: { folders: StoredFolder[]; onBack: () => void; onToggleVisibility: (id: string) => void; onRemoveFolder: (folder: StoredFolder) => void; onAddFolder: () => void; + onAddFolderRecursive?: () => void; }) { return ( @@ -188,36 +204,46 @@ function FoldersView({ {folders.length === 0 ? ( Aucun dossier configuré ) : ( - folders.map((folder) => ( - - - - {folder.name} - - onToggleVisibility(folder.id)} - style={styles.actionBtn} - > - - - onRemoveFolder(folder)} - style={styles.actionBtn} - > - - - - )) + folders.map((folder) => { + const icon = folderIcon(folder); + return ( + + + + {folder.name} + + onToggleVisibility(folder.id)} + style={styles.actionBtn} + > + + + onRemoveFolder(folder)} + style={styles.actionBtn} + > + + + + ); + }) )} Ajouter un dossier + + {onAddFolderRecursive && ( + + + Ajouter (scan récursif) + + )} ); } @@ -504,6 +530,16 @@ const styles = StyleSheet.create({ marginTop: 16, gap: 8, }, + scanRootBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#8E24AA', + borderRadius: 10, + paddingVertical: 12, + marginTop: 10, + gap: 8, + }, addBtnText: { fontSize: 15, color: '#fff', diff --git a/mobile/components/UploadModal.tsx b/mobile/components/UploadModal.tsx index 2dd78a7..dfd8be0 100644 --- a/mobile/components/UploadModal.tsx +++ b/mobile/components/UploadModal.tsx @@ -1,10 +1,10 @@ -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import * as DocumentPicker from 'expo-document-picker'; import { useUpload, UploadFile } from '../hooks/useUpload'; -import { usePollOcr } from '../hooks/usePollOcr'; +import { usePollOcr, SseOcrListener } from '../hooks/usePollOcr'; import { UploadProgress } from './UploadProgress'; import { UploadError } from '../types'; import { apiClient } from '../api/client'; diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts index 18d53c4..b4ecabf 100644 --- a/mobile/constants/api.ts +++ b/mobile/constants/api.ts @@ -9,6 +9,7 @@ export const ENDPOINTS = { VARIANT: '/variants', DEDUP_CHECK: '/resources/dedup-check', OCR_JOBS: '/ocr/jobs', + EVENTS: '/events', HEALTH: '/health', AUTH_LOGIN: '/auth/login', AUTH_REGISTER: '/auth/register', diff --git a/mobile/contexts/SseContext.tsx b/mobile/contexts/SseContext.tsx new file mode 100644 index 0000000..e0a9196 --- /dev/null +++ b/mobile/contexts/SseContext.tsx @@ -0,0 +1,84 @@ +import React, { createContext, useContext, useEffect, useRef, useCallback } from 'react'; +import { apiClient } from '../api/client'; +import { useAuth } from './AuthContext'; + +type OcrDoneCallback = (resourceId: string) => void; + +interface SseContextValue { + onOcrDone: (cb: OcrDoneCallback) => () => void; +} + +const SseContext = createContext({ + onOcrDone: () => () => {}, +}); + +export function SseProvider({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + const listenersRef = useRef>(new Set()); + const cancelRef = useRef<() => void>(() => {}); + const reconnectTimeoutRef = useRef | undefined>(undefined); + + const connect = useCallback(() => { + if (!user) return; + + let cancelled = false; + + const start = async () => { + try { + const cancel = await apiClient.subscribeEvents( + (event, data) => { + if (event === 'ocr_done') { + listenersRef.current.forEach((cb) => cb(data)); + } + }, + () => { + reconnectTimeoutRef.current = setTimeout(connect, 3000); + }, + ); + if (cancelled) { + cancel(); + } else { + cancelRef.current = cancel; + } + } catch { + reconnectTimeoutRef.current = setTimeout(connect, 3000); + } + }; + + start(); + + return () => { + cancelled = true; + }; + }, [user]); + + useEffect(() => { + const cleanup = connect(); + return () => { + cleanup?.(); + cancelRef.current(); + clearTimeout(reconnectTimeoutRef.current); + }; + }, [connect]); + + const onOcrDone = useCallback((cb: OcrDoneCallback) => { + listenersRef.current.add(cb); + return () => { + listenersRef.current.delete(cb); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useOcrDone(onDone?: OcrDoneCallback) { + const ctx = useContext(SseContext); + useEffect(() => { + if (onDone) return ctx.onOcrDone(onDone); + }, [onDone, ctx]); + return { onOcrDone: ctx.onOcrDone }; +} diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts index 578f0f7..1cb82f1 100644 --- a/mobile/hooks/useDeviceFiles.ts +++ b/mobile/hooks/useDeviceFiles.ts @@ -54,6 +54,18 @@ function eventToDeviceFile(event: FileDetectedEvent): DeviceFile { }; } +const SKIP_SUBDIRS = new Set([ + 'Android', 'android', 'data', 'obb', 'cache', + '.thumbnails', '.Trash', 'lost+found', + 'LOST.DIR', 'System Volume Information', + 'com.android', '.cache', 'tmp', '.tmp', +]); + +function shouldSkipDir(name: string): boolean { + if (name.startsWith('.')) return true; + return SKIP_SUBDIRS.has(name); +} + async function scanSafFolder(folder: StoredFolder): Promise { try { const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri); @@ -78,11 +90,62 @@ async function scanSafFolder(folder: StoredFolder): Promise { } } +async function scanMediaAlbum(folder: StoredFolder): Promise { + try { + if (!folder.albumId) return []; + const result = await MediaLibrary.getAssetsAsync({ + album: folder.albumId, + first: 500, + sortBy: 'creationTime', + }); + return result.assets.map((asset) => ({ + id: `media_${folder.id}_${asset.id}`, + uri: asset.uri, + name: asset.filename, + mimeType: asset.mediaType === 'video' ? 'video/mp4' : 'image/jpeg', + size: 0, + createdAt: asset.creationTime + ? new Date(asset.creationTime).toISOString() + : new Date().toISOString(), + folderId: folder.id, + })); + } catch (err) { + return []; + } +} + +async function scanSubdirectories( + baseUri: string, + depth: number = 0, + maxDepth: number = 3, + discovered: Array<{ uri: string; name: string; parentUri: string }> = [] +): Promise> { + if (depth >= maxDepth || discovered.length >= 300) return discovered; + + try { + const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(baseUri); + for (const entryUri of entries) { + const parts = entryUri.split('/'); + const name = decodeURIComponent(parts[parts.length - 1]); + if (shouldSkipDir(name)) continue; + + try { + await FileSystem.StorageAccessFramework.readDirectoryAsync(entryUri); + discovered.push({ uri: entryUri, name, parentUri: baseUri }); + await scanSubdirectories(entryUri, depth + 1, maxDepth, discovered); + } catch {} + } + } catch {} + + return discovered; +} + export function useDeviceFiles() { const [files, setFiles] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasPermission, setHasPermission] = useState(false); const [folders, setFolders] = useState(() => safDirectory.getAll()); + const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered()); const { newFiles, clearNewFiles } = useFileWatcher(); const requestPermission = useCallback(async () => { @@ -125,11 +188,35 @@ export function useDeviceFiles() { } }, []); + const discoverMediaAlbums = useCallback(async (): Promise => { + try { + const albums = await MediaLibrary.getAlbumsAsync(); + let added = 0; + for (const album of albums) { + if (album.title && album.assetCount > 0) { + safDirectory.addMediaFolder(album.id, album.title, ''); + added++; + } + } + if (added > 0) { + setFolders(safDirectory.getAll()); + } + return added; + } catch (err) { + return 0; + } + }, []); + const scanVisibleFolders = useCallback(async () => { const visibleFolders = safDirectory.getVisibleFolders(); if (visibleFolders.length === 0) return; - const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder))); + const results = await Promise.all( + visibleFolders.map((folder) => { + if (folder.source === 'media-library') return scanMediaAlbum(folder); + return scanSafFolder(folder); + }) + ); const safFiles = results.flat(); setFiles((prev) => { @@ -174,7 +261,7 @@ export function useDeviceFiles() { safDirectory.addFolder(dirUri, dirName); setFolders(safDirectory.getAll()); - const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false }); + const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' }); setFiles((prev) => { const existing = new Set(prev.map((f) => f.id)); const merged = [...prev]; @@ -189,6 +276,54 @@ export function useDeviceFiles() { } }, []); + const pickAndScanRecursive = useCallback(async (): Promise<{ addedFolders: number }> => { + try { + const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (!result.granted) return { addedFolders: 0 }; + + const dirUri = result.directoryUri; + const parts = dirUri.split('/'); + const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier'); + + safDirectory.addFolder(dirUri, dirName); + + const subdirs = await scanSubdirectories(dirUri); + if (subdirs.length > 0) { + safDirectory.addBatchFolders( + subdirs.map((d) => ({ uri: d.uri, name: d.name, source: 'recursive', parentUri: d.parentUri })) + ); + } + + setFolders(safDirectory.getAll()); + + if (!discovered) { + safDirectory.setDiscovered(); + setDiscovered(true); + } + + await scanVisibleFolders(); + + return { addedFolders: 1 + subdirs.length }; + } catch (err) { + return { addedFolders: 0 }; + } + }, [discovered, scanVisibleFolders]); + + const runInitialDiscovery = useCallback(async () => { + if (safDirectory.getDiscovered()) return; + + const granted = hasPermission || await requestPermission(); + if (granted) { + const mediaCount = await discoverMediaAlbums(); + if (mediaCount > 0) { + await scanVisibleFolders(); + } + } + + safDirectory.setDiscovered(); + setDiscovered(true); + }, [hasPermission, requestPermission, discoverMediaAlbums, scanVisibleFolders]); + const refreshFolders = useCallback(() => { setFolders(safDirectory.getAll()); }, []); @@ -206,10 +341,8 @@ export function useDeviceFiles() { useEffect(() => { if (newFiles.length === 0) return; - // Persist to MMKV downloadRegistry.addBatch(newFiles); - // Add to in-memory state const newDeviceFiles = newFiles.map(eventToDeviceFile); setFiles((prev) => { const existing = new Set(prev.map((f) => f.id)); @@ -232,8 +365,15 @@ export function useDeviceFiles() { }); scanVisibleFolders(); loadRegistryFiles(); + runInitialDiscovery(); }, []); - return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders }; + return { + files, isLoading, hasPermission, + requestPermission, rescan, + pickDirectory, pickAndScanRecursive, + folders, refreshFolders, + discovered, discoverMediaAlbums, + }; } diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts index 00ca794..1954d28 100644 --- a/mobile/hooks/useLocalFiles.ts +++ b/mobile/hooks/useLocalFiles.ts @@ -4,7 +4,7 @@ import { fileStore } from '../services/fileStore'; import { UnifiedFileItem } from '../types'; export function useLocalFiles() { - const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles(); + const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, pickAndScanRecursive, folders, refreshFolders, discovered, discoverMediaAlbums } = useDeviceFiles(); const lastDeviceCount = useRef(0); useEffect(() => { @@ -76,7 +76,10 @@ export function useLocalFiles() { requestPermission, rescan, pickDirectory, + pickAndScanRecursive, folders, refreshFolders, + discovered, + discoverMediaAlbums, }; } diff --git a/mobile/hooks/usePollOcr.ts b/mobile/hooks/usePollOcr.ts index 68614cf..f171a31 100644 --- a/mobile/hooks/usePollOcr.ts +++ b/mobile/hooks/usePollOcr.ts @@ -1,41 +1,75 @@ -import { useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { apiClient } from '../api/client'; import { ENDPOINTS } from '../constants/api'; import { fileStore } from '../services/fileStore'; - -const MAX_POLL_MS = 120_000; -const POLL_INTERVAL = 3_000; +import { useOcrDone } from '../contexts/SseContext'; export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' }; +type OcrState = Record void; timeout: ReturnType }>; + +let globalOcrState: OcrState = {}; +let globalSetState: React.Dispatch> | null = null; + +function handleOcrDone(resourceId: string) { + const entry = globalOcrState[resourceId]; + if (!entry) return; + clearTimeout(entry.timeout); + + apiClient + .get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`) + .then((detail) => { + const ocrText = detail.data?.ocrText; + if (ocrText) { + fileStore.updatePartial(resourceId, { ocrText }); + } + entry.resolve({ resourceId, status: 'completed' }); + }) + .catch(() => { + entry.resolve({ resourceId, status: 'failed' }); + }) + .finally(() => { + delete globalOcrState[resourceId]; + globalSetState?.(Date.now()); + }); +} + +export function SseOcrListener() { + const { onOcrDone } = useOcrDone(); + + useEffect(() => { + const unsub = onOcrDone(handleOcrDone); + return unsub; + }, [onOcrDone]); + + return null; +} + export function usePollOcr() { - const running = useRef>(new Set()); + const [, forceUpdate] = useState(0); + + useEffect(() => { + globalSetState = forceUpdate; + return () => { + globalSetState = null; + }; + }, []); const pollOcr = useCallback(async (resourceId: string): Promise => { - if (running.current.has(resourceId)) return { resourceId, status: 'failed' }; - running.current.add(resourceId); - - const start = Date.now(); - try { - while (Date.now() - start < MAX_POLL_MS) { - try { - const detail = await apiClient.get<{ data: { ocrText?: string } }>( - `${ENDPOINTS.RESOURCES}/${resourceId}`, - ); - const ocrText = detail.data?.ocrText; - if (ocrText && ocrText.length > 0) { - fileStore.updatePartial(resourceId, { ocrText }); - return { resourceId, status: 'completed' }; - } - } catch { - return { resourceId, status: 'failed' }; - } - await new Promise((r) => setTimeout(r, POLL_INTERVAL)); - } - return { resourceId, status: 'timeout' }; - } finally { - running.current.delete(resourceId); + const existing = globalOcrState[resourceId]; + if (existing) { + return new Promise((resolve) => existing.resolve = resolve); } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + delete globalOcrState[resourceId]; + resolve({ resourceId, status: 'timeout' }); + }, 120_000); + + globalOcrState[resourceId] = { resolve, timeout }; + globalSetState?.(Date.now()); + }); }, []); return { pollOcr }; diff --git a/mobile/hooks/useUpload.ts b/mobile/hooks/useUpload.ts index 4d451fd..d73cf23 100644 --- a/mobile/hooks/useUpload.ts +++ b/mobile/hooks/useUpload.ts @@ -1,8 +1,8 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { File, UploadType } from 'expo-file-system'; import { apiClient } from '../api/client'; import { API_BASE_URL, ENDPOINTS } from '../constants/api'; -import { ApiError, OcrJob, UploadError } from '../types'; +import { ApiError, UploadError } from '../types'; export type UploadFile = { uri: string; type: string; name: string }; export type UploadResult = { name: string; id: string }; @@ -76,15 +76,4 @@ export function useUpload() { }); } -export function useOcrJobStatus(jobId: string) { - return useQuery({ - queryKey: ['ocr', jobId], - queryFn: () => apiClient.get(`${ENDPOINTS.OCR_JOBS}/${jobId}`), - enabled: !!jobId, - refetchInterval: (query: any) => { - const status = query.state.data?.status; - if (status === 'completed' || status === 'failed') return false; - return 1000; - }, - }); -} + diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts index 87fcb0c..4283d49 100644 --- a/mobile/services/safDirectory.ts +++ b/mobile/services/safDirectory.ts @@ -2,6 +2,7 @@ import { createMMKV } from 'react-native-mmkv'; export type SyncMode = 'none' | 'manual' | 'auto'; export type SyncGlobalMode = 'off' | 'auto' | 'manual'; +export type FolderSource = 'saf' | 'media-library' | 'recursive'; export type StoredFolder = { id: string; @@ -10,6 +11,9 @@ export type StoredFolder = { visible: boolean; syncMode: SyncMode; syncCellular: boolean; + source: FolderSource; + albumId?: string; + parentUri?: string; }; const storage = createMMKV({ id: 'vaultdrop-saf' }); @@ -17,6 +21,7 @@ const storage = createMMKV({ id: 'vaultdrop-saf' }); const FOLDERS_KEY = 'saf_folders'; const SYNC_GLOBAL_MODE_KEY = 'sync_global_mode'; const SYNC_GLOBAL_CELLULAR_KEY = 'sync_global_cellular'; +const DISCOVERED_KEY = 'folders_discovered'; function generateId(): string { return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -36,6 +41,10 @@ function getAllRaw(): StoredFolder[] { (f as any).syncCellular = false; migrated = true; } + if (f.source === undefined) { + (f as any).source = 'saf'; + migrated = true; + } } if (migrated) saveAll(parsed); return parsed; @@ -59,12 +68,67 @@ export const safDirectory = { if (folders.some((f) => f.uri === uri)) { return folders.find((f) => f.uri === uri)!; } - const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false }; + const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' }; folders.push(folder); saveAll(folders); return folder; }, + addMediaFolder(albumId: string, name: string, uri: string): StoredFolder { + const folders = getAllRaw(); + const key = `media://${albumId}`; + if (folders.some((f) => f.uri === key)) { + return folders.find((f) => f.uri === key)!; + } + const folder: StoredFolder = { + id: generateId(), + uri: key, + name, + visible: true, + syncMode: 'none', + syncCellular: false, + source: 'media-library', + albumId, + }; + folders.push(folder); + saveAll(folders); + return folder; + }, + + addBatchFolders(folders: Array<{ uri: string; name: string; source?: FolderSource; parentUri?: string }>): StoredFolder[] { + const current = getAllRaw(); + const added: StoredFolder[] = []; + for (const f of folders) { + if (current.some((existing) => existing.uri === f.uri)) continue; + const folder: StoredFolder = { + id: generateId(), + uri: f.uri, + name: f.name, + visible: true, + syncMode: 'none', + syncCellular: false, + source: f.source ?? 'recursive', + parentUri: f.parentUri, + }; + current.push(folder); + added.push(folder); + } + saveAll(current); + return added; + }, + + getDiscovered(): boolean { + return storage.getString(DISCOVERED_KEY) === 'true'; + }, + + setDiscovered() { + storage.set(DISCOVERED_KEY, 'true'); + }, + + resetDiscovered() { + storage.remove(DISCOVERED_KEY); + }, + removeFolder(id: string) { const folders = getAllRaw().filter((f) => f.id !== id); saveAll(folders); diff --git a/mobile/types/index.ts b/mobile/types/index.ts index 9bde065..f18fd49 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -124,6 +124,11 @@ export interface RefreshResponse { refresh_token: string; } +export type SseEvent = { + event: string; + data: string; +}; + export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict'; export interface Device {