diff --git a/mobile/App.tsx b/mobile/App.tsx index 9304cf6..5d4ed82 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -7,6 +7,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; 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 { LoginScreen } from './app/login'; import { RegisterScreen } from './app/register'; import { HomeScreen } from './app/index'; @@ -39,9 +40,10 @@ const queryClient = new QueryClient({ const persister = createMMKVPersister(); function AppNavigator() { - const { user, isLoading } = useAuth(); + const { user, isLoading: authLoading } = useAuth(); + const { isLoading: deviceLoading } = useDevice(); - if (isLoading) { + if (authLoading || (user && deviceLoading)) { return ( @@ -53,7 +55,13 @@ function AppNavigator() { return ( - + {user ? ( <> + + + + + + ); +} + export default function App() { return ( @@ -104,10 +123,7 @@ export default function App() { maxAge: 1000 * 60 * 60 * 24, // 24h }} > - - - - + ); diff --git a/mobile/api/client.ts b/mobile/api/client.ts index 68602aa..da78c89 100644 --- a/mobile/api/client.ts +++ b/mobile/api/client.ts @@ -109,10 +109,6 @@ class ApiClient { async delete(endpoint: string): Promise { return this.request(endpoint, { method: 'DELETE' }); } - - async getFileUrl(fileName: string): Promise<{ data: { url: string; name: string } }> { - return this.request(`${ENDPOINTS.FILE}/${fileName}`); - } } export const apiClient = new ApiClient(API_BASE_URL); diff --git a/mobile/app/device-setup.tsx b/mobile/app/device-setup.tsx new file mode 100644 index 0000000..398fa6a --- /dev/null +++ b/mobile/app/device-setup.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import type { NavigationProp } from '@react-navigation/native'; +import { useDevice } from '../contexts/DeviceContext'; + +export function DeviceSetupScreen() { + const navigation = useNavigation>(); + const { register, deviceName } = useDevice(); + const [name, setName] = useState(deviceName || `${Platform.OS.charAt(0).toUpperCase() + Platform.OS.slice(1)} de ${Platform.OS === 'ios' ? 'l\'utilisateur' : 'utilisateur'}`); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleRegister = async () => { + if (!name.trim()) return; + setIsLoading(true); + setError(null); + try { + await register(name.trim()); + navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); + } catch (err: any) { + setError(err?.message || "Échec de l'enregistrement"); + } finally { + setIsLoading(false); + } + }; + + return ( + + + 📱 + Enregistrement du device + + Ce device doit être enregistré comme espace de stockage pour utiliser VaultDrop. + + + + + {error && {error}} + + + {isLoading ? ( + + ) : ( + Enregistrer + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + padding: 24, + }, + content: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 24, + alignItems: 'center', + gap: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + icon: { + fontSize: 48, + }, + title: { + fontSize: 22, + fontWeight: '700', + color: '#333', + textAlign: 'center', + }, + subtitle: { + fontSize: 15, + color: '#666', + textAlign: 'center', + lineHeight: 22, + }, + input: { + width: '100%', + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 16, + color: '#333', + }, + error: { + color: '#E53935', + fontSize: 14, + textAlign: 'center', + }, + button: { + width: '100%', + backgroundColor: '#1976D2', + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + }, + buttonDisabled: { + backgroundColor: '#ccc', + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/mobile/app/file-detail.tsx b/mobile/app/file-detail.tsx index 4c3621b..93c6e53 100644 --- a/mobile/app/file-detail.tsx +++ b/mobile/app/file-detail.tsx @@ -14,14 +14,14 @@ import { } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { RouteProp, useRoute } from '@react-navigation/native'; -import { useFile, useFileImage, useDownloadFile } from '../hooks/useFiles'; +import { useFile, useDownloadFile } from '../hooks/useFiles'; import { fileStore } from '../services/fileStore'; import { TagChip } from '../components/TagChip'; import { FileThumbnail } from '../components/FileThumbnail'; import { SyncStatusBadge } from '../components/SyncStatusBadge'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { ZoomableImage } from '../components/ZoomableImage'; -import type { Thumbnail, SyncStatus } from '../types'; +import type { Variant, SyncStatus } from '../types'; const SCREEN_WIDTH = Dimensions.get('window').width; @@ -44,13 +44,12 @@ type FileDetailRouteProp = RouteProp; function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; deviceFile?: DeviceFileParam; onSelectImage?: (state: ModalState) => void }) { const isDevice = !!deviceFile; - const { data: imageData, isLoading: imageLoading } = useFileImage(isDevice ? '' : fileId); const { data: fileData } = useFile(isDevice ? '' : fileId); const downloadFile = useDownloadFile(); const [downloading, setDownloading] = useState(false); - const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url); const file = fileData as any; + const uri = isDevice ? deviceFile.localUri : file?.url; const localEntry = fileStore.getByBackendId(fileId); const syncStatus: SyncStatus = isDevice @@ -59,9 +58,10 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev ? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced') : (uri ? 'synced' : 'cloud'); - const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? []) - .filter((t: Thumbnail) => t.resolutionLabel === 'full') - .sort((a: Thumbnail, b: Thumbnail) => a.pageNumber - b.pageNumber); + const fullVariants: Variant[] = (file?.variants ?? []) + .filter((v: Variant) => v.variantType === 'thumbnail_full') + .sort((a: Variant, b: Variant) => a.pageNumber - b.pageNumber); + const fullThumbnails = fullVariants; const hasPages = fullThumbnails.length > 0; @@ -71,14 +71,14 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev try { await downloadFile.mutateAsync({ id: fileId, - backendFileId: fileId, - name: file.data?.name ?? fileId, - mimeType: file.mimeType ?? 'application/octet-stream', - size: file.data?.size ?? 0, - createdAt: file.createdAt ?? new Date().toISOString(), + backendResourceId: fileId, + name: file?.name ?? fileId, + mimeType: file?.mimeType ?? 'application/octet-stream', + size: file?.size ?? 0, + createdAt: file?.createdAt ?? new Date().toISOString(), source: 'cloud', syncStatus: 'cloud', - tags: file.data?.tags ?? [], + tags: file?.tags ?? [], isFolder: false, }); } catch {} finally { @@ -120,11 +120,10 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev ) : file ? ( {syncStatus === 'cloud' && ( - {deviceFile?.name ?? imageData?.data?.name ?? file?.name ?? fileId} + {deviceFile?.name ?? file?.name ?? fileId} - {(deviceFile || imageData?.data?.size != null) && ( + {(deviceFile || file?.size != null) && ( - Taille : {((deviceFile ? 0 : imageData?.data?.size) ?? 0 / 1024).toFixed(1)} Ko + Taille : {((deviceFile ? 0 : file?.size) ?? 0 / 1024).toFixed(1)} Ko )} diff --git a/mobile/app/file-edit.tsx b/mobile/app/file-edit.tsx index e905e14..efa069b 100644 --- a/mobile/app/file-edit.tsx +++ b/mobile/app/file-edit.tsx @@ -13,7 +13,7 @@ import { } from 'react-native'; import { RouteProp, useRoute, useNavigation } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { useFile, useFileImage, useAddTags } from '../hooks/useFiles'; +import { useFile, useAddTags } from '../hooks/useFiles'; import { usePdfGeneration } from '../hooks/usePdfGeneration'; import { useUpload } from '../hooks/useUpload'; import { TagChip } from '../components/TagChip'; @@ -38,10 +38,9 @@ interface FileEditItemProps { function FileEditItem({ fileId, selected, onPress }: FileEditItemProps) { const { data: fileData, isLoading: fileLoading } = useFile(fileId); - const { data: imageData, isLoading: imageLoading } = useFileImage(fileId); const file = fileData as any; - const uri = imageData?.data?.url; - const isLoading = fileLoading || imageLoading; + const uri = file?.url; + const isLoading = fileLoading; if (isLoading) { return ( @@ -135,8 +134,8 @@ export function FileEditScreen() { const imageUris: { uri: string }[] = []; for (const fileId of targetIds) { - const data = await apiClient.get<{ data: { url: string } }>(`${ENDPOINTS.FILES}/${fileId}`); - const url = data?.data?.url; + const data = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${fileId}`); + const url = data?.url; if (url) { imageUris.push({ uri: url }); } diff --git a/mobile/app/folder.tsx b/mobile/app/folder.tsx index 75586bf..00a186e 100644 --- a/mobile/app/folder.tsx +++ b/mobile/app/folder.tsx @@ -3,7 +3,7 @@ import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Alert } import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialIcons } from '@expo/vector-icons'; -import { useDeleteFile, useFileImage, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; +import { useDeleteFile, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; import { UnifiedFileItem } from '../types'; import { isFolder } from '../types'; import { FileThumbnail } from '../components/FileThumbnail'; @@ -34,7 +34,6 @@ function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: selected?: boolean; onFolderPress?: () => void; }) { - const { data, isLoading } = useFileImage(file.id); const folder = isFolder(file); return ( @@ -46,12 +45,11 @@ function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: activeOpacity={0.7} > {selected && ( @@ -137,9 +135,9 @@ export function FolderScreen() { onPress: async () => { for (const id of ids) { const f = files.find((fi) => fi.id === id); - if (f?.backendFileId) { - await apiClient.delete(`${ENDPOINTS.FILES}/${f.backendFileId}`); - fileStore.deleteByBackendId(f.backendFileId); + if (f?.backendResourceId) { + await apiClient.delete(`${ENDPOINTS.RESOURCES}/${f.backendResourceId}`); + fileStore.deleteByBackendId(f.backendResourceId); } else { fileStore.deleteById(id); } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 4354238..a8b4276 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialIcons } from '@expo/vector-icons'; -import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; +import { useDeleteFile, useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; import { UnifiedFileItem, isFolder } from '../types'; import { SearchBar, SearchFilters } from '../components/SearchBar'; import { FileThumbnail } from '../components/FileThumbnail'; @@ -176,7 +176,7 @@ export function HomeScreen() { const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag'); const [tagInput, setTagInput] = useState(''); const addTags = useAddTags(); - const moveFiles = useMoveFiles(); + const moveFiles = useMoveResources(); const { data: foldersData } = useFolders(); const [moveModalVisible, setMoveModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false); @@ -271,9 +271,9 @@ export function HomeScreen() { onPress: async () => { for (const id of ids) { const f = files.find((fi) => fi.id === id); - if (f?.backendFileId) { - await apiClient.delete(`${ENDPOINTS.FILES}/${f.backendFileId}`); - fileStore.deleteByBackendId(f.backendFileId); + if (f?.backendResourceId) { + await apiClient.delete(`${ENDPOINTS.RESOURCES}/${f.backendResourceId}`); + fileStore.deleteByBackendId(f.backendResourceId); } else { fileStore.deleteById(id); } @@ -306,9 +306,8 @@ export function HomeScreen() { const name = tagInput.trim(); if (!name) return; const ids = Array.from(selectedIds); - const tagType = tagModalMode === 'folder' ? 'folder' : 'none'; for (const id of ids) { - await addTags.mutateAsync({ fileId: id, tags: [name], tagType }); + await addTags.mutateAsync({ fileId: id, tags: [name] }); } setTagModalVisible(false); setSelectedIds(new Set()); @@ -316,7 +315,7 @@ export function HomeScreen() { const handleMove = useCallback(async (folderId: string | null) => { const ids = Array.from(selectedIds); - await moveFiles.mutateAsync({ fileIds: ids, parentFileId: folderId }); + await moveFiles.mutateAsync({ resourceIds: ids, parentResourceId: folderId }); setMoveModalVisible(false); setSelectedIds(new Set()); }, [selectedIds, moveFiles]); diff --git a/mobile/app/search.tsx b/mobile/app/search.tsx index cc6eea5..1c39f09 100644 --- a/mobile/app/search.tsx +++ b/mobile/app/search.tsx @@ -28,7 +28,7 @@ export function SearchScreen() { {isLoading && Recherche en cours...} item.id} contentContainerStyle={styles.list} diff --git a/mobile/components/FileCard.tsx b/mobile/components/FileCard.tsx index 2ed5d11..30a5ab5 100644 --- a/mobile/components/FileCard.tsx +++ b/mobile/components/FileCard.tsx @@ -2,29 +2,26 @@ import React from 'react'; import { View, Text, Image, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { FileItem } from '../types'; import { TagChip } from './TagChip'; -import { useFileImage } from '../hooks/useFileImage'; interface FileCardProps { file: FileItem; onPress?: (file: FileItem) => void; } +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function FileCard({ file, onPress }: FileCardProps) { - const { localUri, loading } = useFileImage(file.url, file.name); - const formatSize = (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }; + const imageUri = file.thumbnailUrl || (file.url && file.mimeType?.startsWith('image/') ? file.url : undefined); return ( onPress?.(file)}> - {file.url && ( + {imageUri && ( - {loading && } - {localUri && ( - - )} + )} @@ -54,58 +51,50 @@ const styles = StyleSheet.create({ container: { backgroundColor: '#fff', borderRadius: 8, - padding: 16, - marginBottom: 12, + padding: 12, + marginBottom: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, + shadowOpacity: 0.05, + shadowRadius: 4, elevation: 2, }, imageContainer: { - width: '100%', - height: 200, - borderRadius: 8, + marginBottom: 8, + borderRadius: 4, overflow: 'hidden', - marginBottom: 12, - backgroundColor: '#f0f0f0', }, image: { width: '100%', - height: '100%', - }, - imageLoader: { - position: 'absolute', - top: '50%', - left: '50%', - marginTop: -10, - marginLeft: -10, + height: 120, + borderRadius: 4, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 8, + marginBottom: 4, }, name: { fontSize: 16, fontWeight: '600', + color: '#333', flex: 1, marginRight: 8, }, size: { - fontSize: 14, - color: '#666', + fontSize: 13, + color: '#999', }, preview: { - fontSize: 14, - color: '#444', + fontSize: 13, + color: '#666', marginBottom: 8, - lineHeight: 20, + lineHeight: 18, }, tags: { flexDirection: 'row', flexWrap: 'wrap', - gap: 6, + gap: 4, }, }); diff --git a/mobile/components/TagChip.tsx b/mobile/components/TagChip.tsx index 18acdfa..44920b8 100644 --- a/mobile/components/TagChip.tsx +++ b/mobile/components/TagChip.tsx @@ -21,23 +21,30 @@ export function TagChip({ name, onRemove }: TagChipProps) { const styles = StyleSheet.create({ container: { + flexDirection: 'row', + alignItems: 'center', backgroundColor: '#E3F2FD', borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, - flexDirection: 'row', - alignItems: 'center', + gap: 4, }, text: { fontSize: 12, color: '#1976D2', + fontWeight: '500', }, removeButton: { - marginLeft: 4, + width: 16, + height: 16, + borderRadius: 8, + backgroundColor: '#BBDEFB', + justifyContent: 'center', + alignItems: 'center', }, removeText: { - fontSize: 14, + fontSize: 12, color: '#1976D2', - fontWeight: '600', + fontWeight: '700', }, }); diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts index 589dc51..18d53c4 100644 --- a/mobile/constants/api.ts +++ b/mobile/constants/api.ts @@ -1,16 +1,22 @@ export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.1.17:8080/api/v1'; export const ENDPOINTS = { - FILES: '/files', - FILE: '/files', - UPLOAD: '/files/upload', - SEARCH: '/files/search', - MOVE: '/files/move', - FOLDERS: '/files/folders', + RESOURCES: '/resources', + RESOURCE: '/resources', + UPLOAD: '/resources/upload', + MOVE: '/resources/move', + FOLDERS: '/resources/folders', + VARIANT: '/variants', + DEDUP_CHECK: '/resources/dedup-check', OCR_JOBS: '/ocr/jobs', HEALTH: '/health', AUTH_LOGIN: '/auth/login', AUTH_REGISTER: '/auth/register', AUTH_REFRESH: '/auth/refresh', AUTH_LOGOUT: '/auth/logout', + DEVICES: '/devices', + SYNC_PULL: '/sync/pull', + SYNC_PUSH: '/sync/push', + SHARE: '/resources/:id/share', + ACCESS: '/resources/:id/access', } as const; diff --git a/mobile/contexts/DeviceContext.tsx b/mobile/contexts/DeviceContext.tsx new file mode 100644 index 0000000..037d234 --- /dev/null +++ b/mobile/contexts/DeviceContext.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useDeviceRegistration } from '../hooks/useDeviceRegistration'; +import { useAuth } from './AuthContext'; + +interface DeviceContextType { + isRegistered: boolean | null; + isLoading: boolean; + deviceName: string; + register: (name: string) => Promise<{ id: string; device_name: string; role: string }>; +} + +const DeviceContext = createContext(undefined); + +export function DeviceProvider({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + const registration = useDeviceRegistration(); + const { isRegistered, isLoading, device, register } = registration; + + const value: DeviceContextType = user + ? { + isRegistered, + isLoading, + deviceName: device?.name ?? '', + register, + } + : { + isRegistered: null, + isLoading: false, + deviceName: '', + register: async () => { throw new Error('Not authenticated'); }, + }; + + return ( + + {children} + + ); +} + +export function useDevice() { + const context = useContext(DeviceContext); + if (!context) { + throw new Error('useDevice must be used within a DeviceProvider'); + } + return context; +} diff --git a/mobile/hooks/useAutoSync.ts b/mobile/hooks/useAutoSync.ts index 4171f98..b024b65 100644 --- a/mobile/hooks/useAutoSync.ts +++ b/mobile/hooks/useAutoSync.ts @@ -77,7 +77,6 @@ export function useAutoSync() { if (globalMode === 'auto') { // Mode auto global : tous les fichiers locaux sans backendId - // (pas de filtre par dossier) } else { // Mode manuel : uniquement les fichiers des dossiers en mode auto const allFolders = safDirectory.getAll(); @@ -85,7 +84,7 @@ export function useAutoSync() { allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id) ); pendingFiles = pendingFiles.filter( - (entry) => entry.parentFileId && autoFolderIds.has(entry.parentFileId) + (entry) => entry.parentResourceId && autoFolderIds.has(entry.parentResourceId) ); } @@ -111,7 +110,7 @@ export function useAutoSync() { } } - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); } finally { setIsSyncing(false); isRunning.current = false; diff --git a/mobile/hooks/useDeviceRegistration.ts b/mobile/hooks/useDeviceRegistration.ts new file mode 100644 index 0000000..ffb0cd7 --- /dev/null +++ b/mobile/hooks/useDeviceRegistration.ts @@ -0,0 +1,103 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Platform } from 'react-native'; +import * as SecureStore from 'expo-secure-store'; +import { apiClient } from '../api/client'; +import { ENDPOINTS } from '../constants/api'; +import { Device as DeviceType } from '../types'; + +const DEVICE_ID_KEY = 'vaultdrop_device_id'; +const DEVICE_NAME_KEY = 'vaultdrop_device_name'; +const DEVICE_SERVER_ID_KEY = 'vaultdrop_device_server_id'; + +function generateDefaultDeviceName(): string { + const constants = Platform.constants as Record; + const brand = String(constants?.Manufacturer ?? ''); + const model = String(constants?.Model ?? ''); + const suffix = Math.random().toString(36).slice(2, 6); + const base = [brand, model].filter(Boolean).join(' ') || Platform.OS; + return `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${base} (${suffix})`; +} + +async function getOrCreateDeviceId(): Promise { + let deviceId = await SecureStore.getItemAsync(DEVICE_ID_KEY); + if (!deviceId) { + deviceId = `device_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId); + } + return deviceId; +} + +export async function getStoredDeviceServerId(): Promise { + return SecureStore.getItemAsync(DEVICE_SERVER_ID_KEY); +} + +export async function getStoredDeviceName(): Promise { + return SecureStore.getItemAsync(DEVICE_NAME_KEY); +} + +export function useDeviceRegistration() { + const [device, setDevice] = useState<{ localId: string; serverId: string | null; name: string } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [isRegistered, setIsRegistered] = useState(null); + + const register = useCallback(async (deviceName: string) => { + const localId = await getOrCreateDeviceId(); + const result = await apiClient.post<{ id: string; device_name: string; role: string }>(ENDPOINTS.DEVICES, { + device_name: deviceName, + }); + + await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, result.id); + await SecureStore.setItemAsync(DEVICE_NAME_KEY, result.device_name); + + setDevice({ localId, serverId: result.id, name: result.device_name }); + setIsRegistered(true); + return result; + }, []); + + const checkRegistration = useCallback(async () => { + try { + const localId = await getOrCreateDeviceId(); + const storedName = await getStoredDeviceName(); + + try { + const devices = await apiClient.get(ENDPOINTS.DEVICES); + if (devices.length > 0) { + const existing = devices[0]; + await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, existing.id); + const name = storedName || existing.device_name; + setDevice({ localId, serverId: existing.id, name }); + setIsRegistered(true); + setIsLoading(false); + return; + } + } catch { + // Not registered yet + } + + // Auto-register with a generated name instead of blocking + const autoName = storedName || generateDefaultDeviceName(); + try { + await register(autoName); + } catch { + setDevice({ localId, serverId: null, name: autoName }); + setIsRegistered(false); + } + } catch { + setIsRegistered(false); + } finally { + setIsLoading(false); + } + }, [register]); + + useEffect(() => { + checkRegistration(); + }, [checkRegistration]); + + return { + device, + isLoading, + isRegistered, + register, + checkRegistration, + }; +} diff --git a/mobile/hooks/useFileImage.ts b/mobile/hooks/useFileImage.ts deleted file mode 100644 index 02050fa..0000000 --- a/mobile/hooks/useFileImage.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { downloadAsync, documentDirectory, makeDirectoryAsync, getInfoAsync, deleteAsync } from 'expo-file-system/legacy'; -import { useQueryClient } from '@tanstack/react-query'; -import { apiClient } from '../api/client'; -import { FileItem, PaginatedResponse } from '../types'; - -const CACHE_DIR = `${documentDirectory}file-images/`; - -function getCacheKey(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; - } - return Math.abs(hash).toString(36); -} - -function getExtension(name: string): string { - const dot = name.lastIndexOf('.'); - return dot >= 0 ? name.slice(dot) : '.jpg'; -} - -export function useFileImage(url: string | undefined, fileName: string | undefined) { - const [localUri, setLocalUri] = useState(null); - const [loading, setLoading] = useState(false); - const queryClient = useQueryClient(); - - const refreshUrl = useCallback(async (): Promise => { - if (!fileName) return null; - try { - const res = await apiClient.getFileUrl(fileName); - const freshUrl = res.data.url; - - queryClient.setQueriesData>( - { queryKey: ['files'] }, - (old) => { - if (!old) return old; - return { - ...old, - data: old.data.map((f) => - f.name === fileName ? { ...f, url: freshUrl } : f - ), - }; - } - ); - - return freshUrl; - } catch { - return null; - } - }, [fileName, queryClient]); - - const download = useCallback(async (downloadUrl: string, fileUri: string) => { - await makeDirectoryAsync(CACHE_DIR, { intermediates: true }); - const result = await downloadAsync(downloadUrl, fileUri); - return result.uri; - }, []); - - useEffect(() => { - if (!url || !fileName) return; - - const resolvedUrl = url; - let cancelled = false; - const cacheKey = getCacheKey(fileName); - const ext = getExtension(fileName); - const fileUri = `${CACHE_DIR}${cacheKey}${ext}`; - - async function load() { - const info = await getInfoAsync(fileUri); - if (info.exists) { - if (!cancelled) setLocalUri(info.uri); - return; - } - - setLoading(true); - try { - const uri = await download(resolvedUrl, fileUri); - if (!cancelled) setLocalUri(uri); - } catch { - const freshUrl = await refreshUrl(); - if (freshUrl && !cancelled) { - try { - await deleteAsync(fileUri, { idempotent: true }); - const uri = await download(freshUrl, fileUri); - if (!cancelled) setLocalUri(uri); - } catch { - if (!cancelled) setLocalUri(null); - } - } else if (!cancelled) { - setLocalUri(null); - } - } finally { - if (!cancelled) setLoading(false); - } - } - - load(); - - return () => { - cancelled = true; - }; - }, [url, fileName, download, refreshUrl]); - - return { localUri, loading }; -} diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts index 38d41e1..758130c 100644 --- a/mobile/hooks/useFiles.ts +++ b/mobile/hooks/useFiles.ts @@ -8,7 +8,7 @@ function recordToUnifiedItem(record: ReturnType): Unif if (!record) return null; return { id: record.id, - backendFileId: record.backendId ?? undefined, + backendResourceId: record.backendId ?? undefined, name: record.name, mimeType: record.mimeType, size: record.size, @@ -20,7 +20,8 @@ function recordToUnifiedItem(record: ReturnType): Unif ocrText: record.ocrText ?? undefined, tags: record.tags ?? [], isFolder: record.isFolder === 1, - parentFileId: record.parentFileId ?? undefined, + parentResourceId: record.parentResourceId ?? undefined, + ownerId: record.ownerId ?? undefined, thumbnailUrl: record.thumbnailUrl ?? undefined, isDeviceFile: record.source === 'local' && !record.backendId, }; @@ -38,15 +39,15 @@ function recordsToUnifiedItems(records: ReturnType { if (parentId) { const backendRes = await apiClient.get<{ data: FileItem[] }>( - `/files/folders/${parentId}/files?thumbnail=thumbnail`, + `${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?thumbnail=thumbnail_small`, ); fileStore.mergeFromBackend( backendRes.data.map((f) => ({ @@ -59,7 +60,8 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb ocrText: f.ocrText, tags: f.tags, isFolder: f.isFolder, - parentFileId: f.parentFileId, + parentResourceId: f.parentResourceId, + ownerId: f.ownerId, thumbnailUrl: f.thumbnailUrl, })), ); @@ -72,7 +74,7 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb } const backendRes = await apiClient.get>( - `${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`, + `${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`, ); fileStore.mergeFromBackend( backendRes.data.map((f) => ({ @@ -85,7 +87,8 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb ocrText: f.ocrText, tags: f.tags, isFolder: f.isFolder, - parentFileId: f.parentFileId, + parentResourceId: f.parentResourceId, + ownerId: f.ownerId, thumbnailUrl: f.thumbnailUrl, })), ); @@ -110,34 +113,23 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb export function useFile(id: string) { return useQuery({ - queryKey: ['files', id], - queryFn: () => apiClient.get(`${ENDPOINTS.FILES}/${id}?thumbnail=thumbnail`), + queryKey: ['resources', id], + queryFn: () => apiClient.get(`${ENDPOINTS.RESOURCES}/${id}?thumbnail=thumbnail_small`), enabled: !!id, }); } -export function useFileImage(fileId: string) { - return useQuery({ - queryKey: ['fileImage', fileId], - queryFn: () => - apiClient.get<{ data: { id: string; name: string; url: string; size: number } }>( - `${ENDPOINTS.FILE}/${fileId}`, - ), - enabled: !!fileId, - }); -} - export function useDeleteFile() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { - const result = await apiClient.delete(`${ENDPOINTS.FILES}/${id}`); + const result = await apiClient.delete(`${ENDPOINTS.RESOURCES}/${id}`); fileStore.deleteByBackendId(id); return result; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); }, }); } @@ -146,22 +138,22 @@ export function useAddTags() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ fileId, tags, tagType }: { fileId: string; tags: string[]; tagType?: string }) => - apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }), + mutationFn: ({ fileId, tags }: { fileId: string; tags: string[] }) => + apiClient.post(`${ENDPOINTS.RESOURCES}/${fileId}/tags`, { tags }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); }, }); } -export function useMoveFiles() { +export function useMoveResources() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ fileIds, parentFileId }: { fileIds: string[]; parentFileId: string | null }) => - apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }), + mutationFn: ({ resourceIds, parentResourceId }: { resourceIds: string[]; parentResourceId: string | null }) => + apiClient.post(ENDPOINTS.MOVE, { resource_ids: resourceIds, parent_resource_id: parentResourceId }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); }, }); } @@ -182,7 +174,8 @@ export function useFolders() { ocrText: f.ocrText, tags: f.tags, isFolder: f.isFolder, - parentFileId: f.parentFileId, + parentResourceId: f.parentResourceId, + ownerId: f.ownerId, thumbnailUrl: f.thumbnailUrl, })), ); @@ -209,10 +202,9 @@ export function useDownloadFile() { return file.localUri ?? ''; } - const res = await apiClient.get<{ data: { url: string; name: string } }>( - `/files/${file.backendFileId}`, + const res = await apiClient.get<{ url: string }>( + `${ENDPOINTS.RESOURCES}/${file.backendResourceId ?? file.id}`, ); - const downloadUrl = res.data.url; const { downloadAsync, documentDirectory, makeDirectoryAsync } = await import('expo-file-system/legacy'); const DOWNLOAD_DIR = `${documentDirectory}synced-files/`; @@ -227,28 +219,29 @@ export function useDownloadFile() { const ext = dot >= 0 ? file.name.slice(dot) : ''; const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`; - const result = await downloadAsync(downloadUrl, fileUri); + const result = await downloadAsync(res.url, fileUri); fileStore.upsert({ - id: file.backendFileId ?? file.id, - backendId: file.backendFileId ?? file.id, + id: file.backendResourceId ?? file.id, + backendId: file.backendResourceId ?? file.id, name: file.name, mimeType: file.mimeType, size: file.size, source: 'synced', localUri: result.uri, syncStatus: 'synced', - parentFileId: file.parentFileId ?? null, + parentResourceId: file.parentResourceId ?? null, isFolder: 0, ocrText: file.ocrText ?? null, thumbnailUrl: file.thumbnailUrl ?? null, + ownerId: file.ownerId ?? null, createdAt: file.createdAt, updatedAt: file.updatedAt ?? file.createdAt, lastSyncedAt: new Date().toISOString(), tags: file.tags, }); - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); return result.uri; }, }); @@ -274,7 +267,7 @@ export function useFreeLocalSpace() { } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); }, }); } diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts index 5ca832b..00ca794 100644 --- a/mobile/hooks/useLocalFiles.ts +++ b/mobile/hooks/useLocalFiles.ts @@ -32,7 +32,7 @@ export function useLocalFiles() { for (const entry of registryEntries) { merged.set(entry.id, { id: entry.id, - backendFileId: entry.backendId ?? undefined, + backendResourceId: entry.backendId ?? undefined, name: entry.name, mimeType: entry.mimeType, size: entry.size, @@ -42,7 +42,7 @@ export function useLocalFiles() { localUri: entry.localUri ?? undefined, tags: entry.tags ?? [], isFolder: entry.isFolder === 1, - parentFileId: entry.parentFileId ?? undefined, + parentResourceId: entry.parentResourceId ?? undefined, isDeviceFile: entry.source === 'local' && !entry.backendId, }); } @@ -61,7 +61,7 @@ export function useLocalFiles() { tags: [], isFolder: false, isDeviceFile: true, - parentFileId: df.folderId, + parentResourceId: df.folderId, }); } } diff --git a/mobile/hooks/usePullSync.ts b/mobile/hooks/usePullSync.ts index 8dfb716..ba61a93 100644 --- a/mobile/hooks/usePullSync.ts +++ b/mobile/hooks/usePullSync.ts @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy'; import { fileStore } from '../services/fileStore'; import { apiClient } from '../api/client'; +import { ENDPOINTS } from '../constants/api'; import { setIsSyncing } from './useSyncQueue'; const SYNC_DIR = `${documentDirectory}synced-files/`; @@ -25,9 +26,11 @@ export function usePullSync() { size: number; createdAt: string; url?: string; - }> }>('/files?page=1&limit=100&thumbnail=thumbnail'); + thumbnailUrl?: string; + ownerId?: string; + }> }>(`${ENDPOINTS.RESOURCES}?page=1&limit=100&thumbnail=thumbnail_small`); - const backendFiles = res.data ?? []; + const backendResources = res.data ?? []; const registry = fileStore.getAllSynced(); const existingBackendIds = new Set( registry.filter((e) => e.backendId).map((e) => e.backendId) @@ -35,35 +38,36 @@ export function usePullSync() { let pulled = 0; - for (const bf of backendFiles) { - if (existingBackendIds.has(bf.id)) continue; - if (bf.size === 0) continue; + for (const br of backendResources) { + if (existingBackendIds.has(br.id)) continue; + if (br.size === 0) continue; try { - const detail = await apiClient.get<{ data: { url: string } }>(`/files/${bf.id}`); - const downloadUrl = detail.data.url; + const detail = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${br.id}`); + const downloadUrl = detail.url; await makeDirectoryAsync(SYNC_DIR, { intermediates: true }); - const safeName = bf.name.replace(/[^a-zA-Z0-9._-]/g, '_'); - const fileUri = `${SYNC_DIR}${bf.id}_${safeName}`; + const safeName = br.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const fileUri = `${SYNC_DIR}${br.id}_${safeName}`; const result = await downloadAsync(downloadUrl, fileUri); fileStore.upsert({ - id: bf.id, - backendId: bf.id, - name: bf.name, - mimeType: bf.mimeType, - size: bf.size, + id: br.id, + backendId: br.id, + name: br.name, + mimeType: br.mimeType, + size: br.size, source: 'synced', localUri: result.uri, syncStatus: 'synced', - parentFileId: null, + parentResourceId: null, isFolder: 0, ocrText: null, - thumbnailUrl: null, - createdAt: bf.createdAt, - updatedAt: bf.createdAt, + thumbnailUrl: br.thumbnailUrl ?? null, + ownerId: br.ownerId ?? null, + createdAt: br.createdAt, + updatedAt: br.createdAt, lastSyncedAt: new Date().toISOString(), }); pulled++; @@ -73,7 +77,7 @@ export function usePullSync() { } if (pulled > 0) { - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); } return { pulled }; diff --git a/mobile/hooks/useSearch.ts b/mobile/hooks/useSearch.ts index 451e681..4e0ac3d 100644 --- a/mobile/hooks/useSearch.ts +++ b/mobile/hooks/useSearch.ts @@ -1,15 +1,43 @@ -import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '../api/client'; -import { ENDPOINTS } from '../constants/api'; -import { FileItem, PaginatedResponse } from '../types'; +import { useMemo } from 'react'; +import { fileStore } from '../services/fileStore'; +import { UnifiedFileItem } from '../types'; -export function useSearch(query: string, page: number = 1, limit: number = 20) { - return useQuery({ - queryKey: ['search', query, page, limit], - queryFn: () => - apiClient.get>( - `${ENDPOINTS.FILES}/search?q=${encodeURIComponent(query)}&page=${page}&limit=${limit}` - ), - enabled: query.length > 0, - }); +function recordToUnifiedItem(record: ReturnType): UnifiedFileItem | null { + if (!record) return null; + return { + id: record.id, + backendResourceId: record.backendId ?? undefined, + name: record.name, + mimeType: record.mimeType, + size: record.size, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + source: record.source as UnifiedFileItem['source'], + syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'], + localUri: record.localUri ?? undefined, + ocrText: record.ocrText ?? undefined, + tags: record.tags ?? [], + isFolder: record.isFolder === 1, + parentResourceId: record.parentResourceId ?? undefined, + ownerId: record.ownerId ?? undefined, + thumbnailUrl: record.thumbnailUrl ?? undefined, + isDeviceFile: record.source === 'local' && !record.backendId, + }; +} + +export function useSearch(query: string) { + const results = useMemo(() => { + if (!query.trim()) return []; + const records = fileStore.searchFts(query); + if (records.length === 0) { + const fallback = fileStore.search(query); + return fallback.map((r) => recordToUnifiedItem(r)!).filter(Boolean); + } + return records.map((r) => recordToUnifiedItem(r)!).filter(Boolean); + }, [query]); + + return { + data: results, + isLoading: false, + }; } diff --git a/mobile/hooks/useShare.ts b/mobile/hooks/useShare.ts new file mode 100644 index 0000000..274940e --- /dev/null +++ b/mobile/hooks/useShare.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; +import { ShareEntry } from '../types'; + +function shareEndpoint(resourceId: string) { + return `/resources/${resourceId}/share`; +} + +export function useShares(resourceId: string) { + return useQuery({ + queryKey: ['shares', resourceId], + queryFn: () => apiClient.get(shareEndpoint(resourceId)), + enabled: !!resourceId, + }); +} + +export function useGrantShare() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ resourceId, subjectUserId, role }: { resourceId: string; subjectUserId: string; role: string }) => + apiClient.post(shareEndpoint(resourceId), { subject_user_id: subjectUserId, role }), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] }); + }, + }); +} + +export function useRevokeShare() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ resourceId, userId }: { resourceId: string; userId: string }) => + apiClient.delete(`${shareEndpoint(resourceId)}/${userId}`), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] }); + }, + }); +} + +export function useCheckAccess(resourceId: string) { + return useQuery({ + queryKey: ['access', resourceId], + queryFn: () => apiClient.get<{ role: string; access: boolean }>(`/resources/${resourceId}/access`), + enabled: !!resourceId, + }); +} diff --git a/mobile/hooks/useSyncPull.ts b/mobile/hooks/useSyncPull.ts new file mode 100644 index 0000000..69b08a3 --- /dev/null +++ b/mobile/hooks/useSyncPull.ts @@ -0,0 +1,23 @@ +import { useCallback, useRef } from 'react'; +import { apiClient } from '../api/client'; +import { ENDPOINTS } from '../constants/api'; +import { SyncQueueItem } from '../types'; + +export function useSyncPull() { + const isRunning = useRef(false); + + const pull = useCallback(async (locationId?: string) => { + if (isRunning.current) return { items: [] }; + isRunning.current = true; + + try { + const body = locationId ? { location_id: locationId } : {}; + const result = await apiClient.post(ENDPOINTS.SYNC_PULL, body); + return { items: result }; + } finally { + isRunning.current = false; + } + }, []); + + return { pull }; +} diff --git a/mobile/hooks/useSyncPush.ts b/mobile/hooks/useSyncPush.ts new file mode 100644 index 0000000..0c18502 --- /dev/null +++ b/mobile/hooks/useSyncPush.ts @@ -0,0 +1,23 @@ +import { useCallback, useRef } from 'react'; +import { apiClient } from '../api/client'; +import { ENDPOINTS } from '../constants/api'; + +export function useSyncPush() { + const isRunning = useRef(false); + + const push = useCallback(async (locationId: string) => { + if (isRunning.current) return { pending: 0 }; + isRunning.current = true; + + try { + const result = await apiClient.post<{ pending: number; message: string }>(ENDPOINTS.SYNC_PUSH, { + location_id: locationId, + }); + return { pending: result.pending }; + } finally { + isRunning.current = false; + } + }, []); + + return { push }; +} diff --git a/mobile/hooks/useSyncQueue.ts b/mobile/hooks/useSyncQueue.ts index b479975..835513c 100644 --- a/mobile/hooks/useSyncQueue.ts +++ b/mobile/hooks/useSyncQueue.ts @@ -36,8 +36,8 @@ export function useSyncQueue() { count++; } else { // mode manuel : uniquement les fichiers des dossiers en mode auto - if (!entry.parentFileId) continue; - const folder = safDirectory.getAll().find((f) => f.id === entry.parentFileId); + if (!entry.parentResourceId) continue; + const folder = safDirectory.getAll().find((f) => f.id === entry.parentResourceId); if (folder && folder.syncMode === 'auto') { count++; } diff --git a/mobile/hooks/useUpload.ts b/mobile/hooks/useUpload.ts index ce2ea76..4d451fd 100644 --- a/mobile/hooks/useUpload.ts +++ b/mobile/hooks/useUpload.ts @@ -71,7 +71,7 @@ export function useUpload() { return { uploaded, errors }; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['files'] }); + queryClient.invalidateQueries({ queryKey: ['resources'] }); }, }); } diff --git a/mobile/services/fileStore/index.ts b/mobile/services/fileStore/index.ts index 9a690a4..ddfaa84 100644 --- a/mobile/services/fileStore/index.ts +++ b/mobile/services/fileStore/index.ts @@ -4,7 +4,9 @@ import { eq, like, or, and, desc, asc, sql, isNull } from 'drizzle-orm'; import { files, fileTags, deletedFiles } from './schema'; import type { Tag } from '../../types'; -const DB_NAME = 'vaultdrop.db'; +const DB_NAME = 'vaultdrop-v3.db'; +const SCHEMA_VERSION_KEY = 'schema_version'; +const SCHEMA_VERSION = 3; let _db: ReturnType | null = null; let _sqliteDb: SQLite.SQLiteDatabase | null = null; @@ -14,9 +16,41 @@ export function initDB() { _sqliteDb = SQLite.openDatabaseSync(DB_NAME); _sqliteDb.execSync('PRAGMA journal_mode = WAL;'); _sqliteDb.execSync('PRAGMA foreign_keys = ON;'); - _db = drizzle(_sqliteDb); - _sqliteDb.execSync(` + const existingVersion = _sqliteDb.getFirstSync<{ version: number }>( + `SELECT name as version FROM sqlite_master WHERE type='table' AND name='schema_version'` + ); + + if (!existingVersion) { + _sqliteDb.execSync(`CREATE TABLE schema_version (version INTEGER PRIMARY KEY);`); + } + + const versionRow = _sqliteDb.getFirstSync<{ version: number }>( + `SELECT version FROM schema_version ORDER BY version DESC LIMIT 1` + ); + const currentVersion = versionRow?.version ?? 0; + + if (currentVersion < SCHEMA_VERSION) { + dropAllTables(_sqliteDb); + createSchema(_sqliteDb); + _sqliteDb.execSync(`INSERT INTO schema_version (version) VALUES (${SCHEMA_VERSION});`); + } + + _db = drizzle(_sqliteDb); + return _db; +} + +function dropAllTables(db: SQLite.SQLiteDatabase) { + db.execSync(`DROP TABLE IF EXISTS file_tags;`); + db.execSync(`DROP TABLE IF EXISTS files;`); + db.execSync(`DROP TABLE IF EXISTS deleted_files;`); + db.execSync(`DROP TABLE IF EXISTS device_info;`); + db.execSync(`DROP TABLE IF EXISTS resources_fts;`); + db.execSync(`DROP TABLE IF EXISTS schema_version;`); +} + +function createSchema(db: SQLite.SQLiteDatabase) { + db.execSync(` CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, backend_id TEXT, @@ -26,33 +60,75 @@ export function initDB() { source TEXT NOT NULL DEFAULT 'cloud', local_uri TEXT, sync_status TEXT NOT NULL DEFAULT 'cloud', - parent_file_id TEXT, + parent_resource_id TEXT, is_folder INTEGER NOT NULL DEFAULT 0, ocr_text TEXT, thumbnail_url TEXT, + owner_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_synced_at TEXT ); + `); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_backend_id ON files(backend_id);`); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_parent_resource_id ON files(parent_resource_id);`); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_source ON files(source);`); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_is_folder ON files(is_folder);`); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_sync_status ON files(sync_status);`); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_files_owner_id ON files(owner_id);`); + db.execSync(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);`); + + db.execSync(` CREATE TABLE IF NOT EXISTS file_tags ( id TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, - tag_name TEXT NOT NULL, - tag_type TEXT NOT NULL DEFAULT 'none' + tag_name TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_files_backend_id ON files(backend_id); - CREATE INDEX IF NOT EXISTS idx_files_parent_id ON files(parent_file_id); - CREATE INDEX IF NOT EXISTS idx_files_source ON files(source); - CREATE INDEX IF NOT EXISTS idx_files_is_folder ON files(is_folder); - CREATE INDEX IF NOT EXISTS idx_files_sync_status ON files(sync_status); - CREATE INDEX IF NOT EXISTS idx_file_tags_file_id ON file_tags(file_id); + `); + db.execSync(`CREATE INDEX IF NOT EXISTS idx_file_tags_file_id ON file_tags(file_id);`); + + db.execSync(` CREATE TABLE IF NOT EXISTS deleted_files ( id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL ); `); - return _db; + db.execSync(` + CREATE TABLE IF NOT EXISTS device_info ( + id TEXT PRIMARY KEY, + server_id TEXT, + device_name TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + registered_at TEXT + ); + `); + + db.execSync(` + CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5( + name, + ocr_text, + content='files', + content_rowid='rowid' + ); + `); + + db.execSync(` + CREATE TRIGGER IF NOT EXISTS resources_fts_insert AFTER INSERT ON files BEGIN + INSERT INTO resources_fts(rowid, name, ocr_text) VALUES (new.rowid, new.name, new.ocr_text); + END; + `); + db.execSync(` + CREATE TRIGGER IF NOT EXISTS resources_fts_delete AFTER DELETE ON files BEGIN + INSERT INTO resources_fts(resources_fts, rowid, name, ocr_text) VALUES('delete', old.rowid, old.name, old.ocr_text); + END; + `); + db.execSync(` + CREATE TRIGGER IF NOT EXISTS resources_fts_update AFTER UPDATE ON files BEGIN + INSERT INTO resources_fts(resources_fts, rowid, name, ocr_text) VALUES('delete', old.rowid, old.name, old.ocr_text); + INSERT INTO resources_fts(rowid, name, ocr_text) VALUES (new.rowid, new.name, new.ocr_text); + END; + `); } function getDb() { @@ -69,18 +145,35 @@ export type FileRecord = { source: string; localUri: string | null; syncStatus: string; - parentFileId: string | null; + parentResourceId: string | null; isFolder: number; ocrText: string | null; thumbnailUrl: string | null; + ownerId: string | null; createdAt: string; updatedAt: string; lastSyncedAt: string | null; tags?: Tag[]; }; -type FileRow = typeof files.$inferSelect; -type TagRow = typeof fileTags.$inferSelect; +type FileRow = { + id: string; + backendId: string | null; + name: string; + mimeType: string; + size: number; + source: string; + localUri: string | null; + syncStatus: string; + parentResourceId: string | null; + isFolder: number; + ocrText: string | null; + thumbnailUrl: string | null; + ownerId: string | null; + createdAt: string; + updatedAt: string; + lastSyncedAt: string | null; +}; function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord { return { @@ -92,10 +185,11 @@ function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord { source: row.source, localUri: row.localUri, syncStatus: row.syncStatus, - parentFileId: row.parentFileId, + parentResourceId: row.parentResourceId, isFolder: row.isFolder, ocrText: row.ocrText, thumbnailUrl: row.thumbnailUrl, + ownerId: row.ownerId, createdAt: row.createdAt, updatedAt: row.updatedAt, lastSyncedAt: row.lastSyncedAt, @@ -106,7 +200,7 @@ function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord { function getTagsForFile(fileId: string): Tag[] { const d = getDb(); const rows = d.select().from(fileTags).where(eq(fileTags.fileId, fileId)).all(); - return rows.map((r) => ({ id: r.tagName, tag_name: r.tagName, tag_type: r.tagType })); + return rows.map((r) => ({ id: r.tagName, tag_name: r.tagName })); } function setTagsForFile(fileId: string, tags: Tag[]) { @@ -118,7 +212,6 @@ function setTagsForFile(fileId: string, tags: Tag[]) { id: `${fileId}_${t.id || t.tag_name}`, fileId, tagName: t.tag_name, - tagType: t.tag_type, })), ).run(); } @@ -134,10 +227,11 @@ function upsertRow(file: FileRecord) { source: file.source, localUri: file.localUri, syncStatus: file.syncStatus, - parentFileId: file.parentFileId, + parentResourceId: file.parentResourceId, isFolder: file.isFolder, ocrText: file.ocrText, thumbnailUrl: file.thumbnailUrl, + ownerId: file.ownerId, createdAt: file.createdAt, updatedAt: file.updatedAt, lastSyncedAt: file.lastSyncedAt, @@ -151,10 +245,11 @@ function upsertRow(file: FileRecord) { source: file.source, localUri: file.localUri, syncStatus: file.syncStatus, - parentFileId: file.parentFileId, + parentResourceId: file.parentResourceId, isFolder: file.isFolder, ocrText: file.ocrText, thumbnailUrl: file.thumbnailUrl, + ownerId: file.ownerId, updatedAt: file.updatedAt, lastSyncedAt: file.lastSyncedAt, }, @@ -179,14 +274,14 @@ export const fileStore = { getById(id: string): FileRecord | null { const d = getDb(); - const row = d.select().from(files).where(eq(files.id, id)).get(); + const row = d.select().from(files).where(eq(files.id, id)).get() as FileRow | undefined; if (!row) return null; return rowToRecord(row, getTagsForFile(id)); }, getByBackendId(backendId: string): FileRecord | null { const d = getDb(); - const row = d.select().from(files).where(eq(files.backendId, backendId)).get(); + const row = d.select().from(files).where(eq(files.backendId, backendId)).get() as FileRow | undefined; if (!row) return null; return rowToRecord(row, getTagsForFile(row.id)); }, @@ -194,18 +289,18 @@ export const fileStore = { getRootFolders(): FileRecord[] { const d = getDb(); const rows = d.select().from(files) - .where(and(eq(files.isFolder, 1), isNull(files.parentFileId))) + .where(and(eq(files.isFolder, 1), isNull(files.parentResourceId))) .orderBy(asc(files.name)) - .all(); + .all() as FileRow[]; return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, getChildrenByParent(parentId: string): FileRecord[] { const d = getDb(); const rows = d.select().from(files) - .where(eq(files.parentFileId, parentId)) + .where(eq(files.parentResourceId, parentId)) .orderBy(desc(files.isFolder), desc(files.createdAt)) - .all(); + .all() as FileRow[]; return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, @@ -215,16 +310,16 @@ export const fileStore = { const countRow = d.select({ count: sql`count(*)` }) .from(files) - .where(and(isNull(files.parentFileId), eq(files.isFolder, 0))) + .where(and(isNull(files.parentResourceId), eq(files.isFolder, 0))) .get(); const total = countRow?.count ?? 0; const rows = d.select().from(files) - .where(and(isNull(files.parentFileId), eq(files.isFolder, 0))) + .where(and(isNull(files.parentResourceId), eq(files.isFolder, 0))) .orderBy(desc(files.createdAt)) .limit(limit) .offset(offset) - .all(); + .all() as FileRow[]; return { files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))), @@ -237,7 +332,29 @@ export const fileStore = { const rows = d.select().from(files) .where(eq(files.isFolder, 1)) .orderBy(asc(files.name)) - .all(); + .all() as FileRow[]; + return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); + }, + + searchFts(query: string): FileRecord[] { + const d = getDb(); + const sanitized = query.replace(/['"]/g, '').trim(); + if (!sanitized) return []; + const ftsPattern = sanitized.split(/\s+/).map(w => `"${w}"`).join(' OR '); + const sqlQuery = ` + SELECT f.* FROM files f + JOIN resources_fts r ON r.rowid = f.rowid + WHERE resources_fts MATCH ? + ORDER BY rank + LIMIT 100 + `; + const sqliteDb = _sqliteDb!; + const stmt = sqliteDb.prepareSync(sqlQuery); + const result = stmt.executeSync(ftsPattern); + const rows: FileRow[] = []; + for (const r of result) { + rows.push(r as unknown as FileRow); + } return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, @@ -248,7 +365,7 @@ export const fileStore = { .where(or(like(files.name, pattern), like(files.ocrText, pattern))) .orderBy(desc(files.createdAt)) .limit(100) - .all(); + .all() as FileRow[]; return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, @@ -262,15 +379,16 @@ export const fileStore = { ocrText?: string; tags?: Tag[]; isFolder: boolean; - parentFileId?: string; + parentResourceId?: string; thumbnailUrl?: string; + ownerId?: string; }>) { const d = getDb(); const now = new Date().toISOString(); d.transaction(() => { for (const bf of backendFiles) { - const existing = d.select().from(files).where(eq(files.backendId, bf.id)).get(); + const existing = d.select().from(files).where(eq(files.backendId, bf.id)).get() as FileRow | undefined; const source = existing && existing.localUri ? 'synced' : 'cloud'; const syncStatus = existing && existing.localUri @@ -286,10 +404,11 @@ export const fileStore = { source, localUri: existing?.localUri ?? null, syncStatus, - parentFileId: bf.parentFileId ?? null, + parentResourceId: bf.parentResourceId ?? null, isFolder: bf.isFolder ? 1 : 0, ocrText: bf.ocrText ?? null, thumbnailUrl: bf.thumbnailUrl ?? null, + ownerId: bf.ownerId ?? null, createdAt: bf.createdAt, updatedAt: bf.updatedAt ?? now, lastSyncedAt: now, @@ -327,10 +446,11 @@ export const fileStore = { source: 'local', localUri: df.uri, syncStatus: 'local', - parentFileId: df.folderId ?? null, + parentResourceId: df.folderId ?? null, isFolder: 0, ocrText: null, thumbnailUrl: null, + ownerId: null, createdAt: df.createdAt, updatedAt: now, lastSyncedAt: null, @@ -348,8 +468,9 @@ export const fileStore = { if (updates.source !== undefined) setFields.source = updates.source; if (updates.thumbnailUrl !== undefined) setFields.thumbnailUrl = updates.thumbnailUrl; if (updates.ocrText !== undefined) setFields.ocrText = updates.ocrText; - if (updates.parentFileId !== undefined) setFields.parentFileId = updates.parentFileId; + if (updates.parentResourceId !== undefined) setFields.parentResourceId = updates.parentResourceId; if (updates.name !== undefined) setFields.name = updates.name; + if (updates.ownerId !== undefined) setFields.ownerId = updates.ownerId; setFields.updatedAt = new Date().toISOString(); d.update(files).set(setFields).where(eq(files.id, id)).run(); @@ -390,7 +511,7 @@ export const fileStore = { deleteByBackendId(backendId: string) { const d = getDb(); - const row = d.select().from(files).where(eq(files.backendId, backendId)).get(); + const row = d.select().from(files).where(eq(files.backendId, backendId)).get() as FileRow | undefined; if (row) this.markDeleted(row.id); d.delete(files).where(eq(files.backendId, backendId)).run(); }, @@ -411,7 +532,7 @@ export const fileStore = { const d = getDb(); const rows = d.select().from(files) .where(or(eq(files.source, 'local'), eq(files.source, 'synced'))) - .all(); + .all() as FileRow[]; return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, @@ -419,7 +540,7 @@ export const fileStore = { const d = getDb(); const rows = d.select().from(files) .where(eq(files.source, 'synced')) - .all(); + .all() as FileRow[]; return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, @@ -427,7 +548,7 @@ export const fileStore = { const d = getDb(); const rows = d.select().from(files) .where(and(eq(files.syncStatus, 'local'), sql`${files.backendId} IS NULL`)) - .all(); + .all() as FileRow[]; return rows.map((r) => rowToRecord(r, getTagsForFile(r.id))); }, }; diff --git a/mobile/services/fileStore/migrate.ts b/mobile/services/fileStore/migrate.ts index 7aec87b..38cee24 100644 --- a/mobile/services/fileStore/migrate.ts +++ b/mobile/services/fileStore/migrate.ts @@ -1,112 +1,5 @@ -import { createMMKV } from 'react-native-mmkv'; -import { fileStore, FileRecord } from './index'; -import type { Tag, SyncStatus } from '../../types'; - -const metadataStorage = createMMKV({ id: 'vaultdrop-metadata' }); -const localFilesStorage = createMMKV({ id: 'vaultdrop-local-files' }); - -interface LegacyCachedFiles { - files: Array<{ - id: string; - name: string; - mimeType: string; - size: number; - createdAt: string; - updatedAt: string; - ocrText?: string; - tags?: Tag[]; - isFolder: boolean; - parentFileId?: string; - url?: string; - thumbnailUrl?: string; - }>; - page: number; - total: number; -} - -interface LegacyRegistryBlob { - entries: Record; -} +import { initDB } from './index'; export function migrateFromLegacy() { - const count = fileStore.count(); - if (count > 0) return; - - try { - const rawMetadata = metadataStorage.getString('backend_files_cache'); - if (rawMetadata) { - const cached: LegacyCachedFiles = JSON.parse(rawMetadata); - for (const f of cached.files) { - fileStore.upsert({ - id: f.id, - backendId: f.id, - name: f.name, - mimeType: f.mimeType, - size: f.size, - source: 'cloud', - localUri: null, - syncStatus: 'cloud', - parentFileId: f.parentFileId ?? null, - isFolder: f.isFolder ? 1 : 0, - ocrText: f.ocrText ?? null, - thumbnailUrl: f.thumbnailUrl ?? null, - createdAt: f.createdAt, - updatedAt: f.updatedAt, - lastSyncedAt: new Date().toISOString(), - tags: f.tags, - }); - } - } - } catch {} - - try { - const rawRegistry = localFilesStorage.getString('local_files_v2'); - if (rawRegistry) { - const blob: LegacyRegistryBlob = JSON.parse(rawRegistry); - for (const entry of Object.values(blob.entries)) { - const existing = fileStore.getByBackendId(entry.backendFileId ?? ''); - const source = entry.backendFileId - ? (entry.syncStatus === 'synced' ? 'synced' : 'cloud') - : 'local'; - - fileStore.upsert({ - id: entry.id, - backendId: entry.backendFileId ?? null, - name: entry.name, - mimeType: entry.mimeType, - size: entry.size, - source, - localUri: entry.localUri, - syncStatus: entry.syncStatus, - parentFileId: entry.folderId ?? null, - isFolder: 0, - ocrText: null, - thumbnailUrl: null, - createdAt: entry.createdAt, - updatedAt: entry.createdAt, - lastSyncedAt: entry.backendFileId ? new Date().toISOString() : null, - tags: entry.tags, - }); - - if (existing && entry.localUri) { - fileStore.updatePartial(entry.id, { - localUri: entry.localUri, - source: 'synced', - syncStatus: 'synced', - }); - } - } - } - } catch {} + initDB(); } diff --git a/mobile/services/fileStore/schema.ts b/mobile/services/fileStore/schema.ts index 967e33e..c445717 100644 --- a/mobile/services/fileStore/schema.ts +++ b/mobile/services/fileStore/schema.ts @@ -11,20 +11,22 @@ export const files = sqliteTable( source: text('source').notNull().default('cloud'), localUri: text('local_uri'), syncStatus: text('sync_status').notNull().default('cloud'), - parentFileId: text('parent_file_id'), + parentResourceId: text('parent_resource_id'), isFolder: integer('is_folder').notNull().default(0), ocrText: text('ocr_text'), thumbnailUrl: text('thumbnail_url'), + ownerId: text('owner_id'), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), lastSyncedAt: text('last_synced_at'), }, (t) => [ index('idx_files_backend_id').on(t.backendId), - index('idx_files_parent_id').on(t.parentFileId), + index('idx_files_parent_resource_id').on(t.parentResourceId), index('idx_files_source').on(t.source), index('idx_files_is_folder').on(t.isFolder), index('idx_files_sync_status').on(t.syncStatus), + index('idx_files_owner_id').on(t.ownerId), ], ); @@ -36,7 +38,6 @@ export const fileTags = sqliteTable( .notNull() .references(() => files.id, { onDelete: 'cascade' }), tagName: text('tag_name').notNull(), - tagType: text('tag_type').notNull().default('none'), }, (t) => [index('idx_file_tags_file_id').on(t.fileId)], ); @@ -45,3 +46,11 @@ export const deletedFiles = sqliteTable('deleted_files', { id: text('id').primaryKey(), deletedAt: text('deleted_at').notNull(), }); + +export const deviceInfo = sqliteTable('device_info', { + id: text('id').primaryKey(), + serverId: text('server_id'), + deviceName: text('device_name').notNull().default(''), + platform: text('platform').notNull().default(''), + registeredAt: text('registered_at'), +}); diff --git a/mobile/types/index.ts b/mobile/types/index.ts index 1cf531e..9bde065 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -1,7 +1,7 @@ -export interface Thumbnail { +export interface Variant { id: string; pageNumber: number; - resolutionLabel: string; + variantType: string; width: number; height: number; url: string; @@ -10,7 +10,7 @@ export interface Thumbnail { export interface UnifiedFileItem { id: string; - backendFileId?: string; + backendResourceId?: string; name: string; mimeType: string; size: number; @@ -22,10 +22,11 @@ export interface UnifiedFileItem { ocrText?: string; tags: Tag[]; isFolder: boolean; - parentFileId?: string; + parentResourceId?: string; + ownerId?: string; url?: string; thumbnailUrl?: string; - thumbnails?: Thumbnail[]; + variants?: Variant[]; isDeviceFile?: boolean; } @@ -38,7 +39,6 @@ export function isFolder(file: UnifiedFileItem | { isFolder: boolean }): boolean export interface Tag { id: string; tag_name: string; - tag_type: string; } export interface OcrJob { @@ -125,3 +125,34 @@ export interface RefreshResponse { } export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict'; + +export interface Device { + id: string; + device_name: string; + role: string; +} + +export interface SyncQueueItem { + id: string; + resourceId: string; + storageLocationId: string; + operation: string; + status: string; + attempts: number; + createdAt: string; + updatedAt: string; +} + +export interface ShareEntry { + user_id: string; + role: string; +} + +export interface ResourcePlacement { + id: string; + resourceId: string; + storageLocationId: string; + status: string; + storageKey: string | null; + syncedAt: string | null; +}