sqlite database

This commit is contained in:
m
2026-07-28 00:16:31 +02:00
parent e8ceb99a5d
commit cae6c4d72d
23 changed files with 2728 additions and 744 deletions
+24 -4
View File
@@ -1,10 +1,11 @@
import React from 'react'; import React, { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { ActivityIndicator, View } from 'react-native'; import { ActivityIndicator, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
import { LoginScreen } from './app/login'; import { LoginScreen } from './app/login';
import { RegisterScreen } from './app/register'; import { RegisterScreen } from './app/register';
@@ -20,9 +21,22 @@ import { FolderScreen } from './app/folder';
import { OnboardingScreen } from './app/onboarding'; import { OnboardingScreen } from './app/onboarding';
import { SyncDetailScreen } from './app/sync-detail'; import { SyncDetailScreen } from './app/sync-detail';
import { onboardingStorage } from './services/onboardingStorage'; import { onboardingStorage } from './services/onboardingStorage';
import { initDB } from './services/fileStore';
import { migrateFromLegacy } from './services/fileStore/migrate';
import { createMMKVPersister } from './services/mmkvPersister';
initDB();
migrateFromLegacy();
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const queryClient = new QueryClient(); const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24h
},
},
});
const persister = createMMKVPersister();
function AppNavigator() { function AppNavigator() {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@@ -83,12 +97,18 @@ function AppNavigator() {
export default function App() { export default function App() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}> <PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24, // 24h
}}
>
<AuthProvider> <AuthProvider>
<AppNavigator /> <AppNavigator />
<StatusBar style="auto" /> <StatusBar style="auto" />
</AuthProvider> </AuthProvider>
</QueryClientProvider> </PersistQueryClientProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }
+2 -1
View File
@@ -45,7 +45,8 @@
"videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser." "videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser."
} }
], ],
"expo-image" "expo-image",
"expo-sqlite"
] ]
} }
} }
+4 -4
View File
@@ -14,9 +14,8 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { RouteProp, useRoute } from '@react-navigation/native'; import { RouteProp, useRoute } from '@react-navigation/native';
import { useFile, useFileImage } from '../hooks/useFiles'; import { useFile, useFileImage, useDownloadFile } from '../hooks/useFiles';
import { useDownloadFile } from '../hooks/useUnifiedFiles'; import { fileStore } from '../services/fileStore';
import { localFileRegistry } from '../services/localFileRegistry';
import { TagChip } from '../components/TagChip'; import { TagChip } from '../components/TagChip';
import { FileThumbnail } from '../components/FileThumbnail'; import { FileThumbnail } from '../components/FileThumbnail';
import { SyncStatusBadge } from '../components/SyncStatusBadge'; import { SyncStatusBadge } from '../components/SyncStatusBadge';
@@ -53,7 +52,7 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev
const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url); const uri = isDevice ? deviceFile.localUri : (imageData?.data?.url);
const file = fileData as any; const file = fileData as any;
const localEntry = localFileRegistry.getByBackendId(fileId); const localEntry = fileStore.getByBackendId(fileId);
const syncStatus: SyncStatus = isDevice const syncStatus: SyncStatus = isDevice
? 'local' ? 'local'
: localEntry : localEntry
@@ -77,6 +76,7 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev
mimeType: file.mimeType ?? 'application/octet-stream', mimeType: file.mimeType ?? 'application/octet-stream',
size: file.data?.size ?? 0, size: file.data?.size ?? 0,
createdAt: file.createdAt ?? new Date().toISOString(), createdAt: file.createdAt ?? new Date().toISOString(),
source: 'cloud',
syncStatus: 'cloud', syncStatus: 'cloud',
tags: file.data?.tags ?? [], tags: file.data?.tags ?? [],
isFolder: false, isFolder: false,
+54 -19
View File
@@ -3,11 +3,16 @@ import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Alert }
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useDeleteFile, useFileImage } from '../hooks/useFiles'; import { useDeleteFile, useFileImage, useFiles, useFreeLocalSpace } from '../hooks/useFiles';
import { useUnifiedFilesByParent } from '../hooks/useUnifiedFiles'; import { UnifiedFileItem } from '../types';
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
import { isFolder } from '../types'; import { isFolder } from '../types';
import { FileThumbnail } from '../components/FileThumbnail'; import { FileThumbnail } from '../components/FileThumbnail';
import { fileStore } from '../services/fileStore';
import { downloadRegistry } from '../services/downloadRegistry';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { useQueryClient } from '@tanstack/react-query';
import { deleteAsync } from 'expo-file-system/legacy';
const NUM_COLUMNS = 3; const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width; const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -65,8 +70,10 @@ export function FolderScreen() {
const route = useRoute<FolderRouteProp>(); const route = useRoute<FolderRouteProp>();
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const { folderId, folderName } = route.params; const { folderId, folderName } = route.params;
const { data, isLoading } = useUnifiedFilesByParent(folderId); const { data, isLoading } = useFiles(folderId);
const deleteFile = useDeleteFile(); const deleteFile = useDeleteFile();
const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0; const selectionMode = selectedIds.size > 0;
@@ -76,7 +83,7 @@ export function FolderScreen() {
}, [navigation, folderName]); }, [navigation, folderName]);
const files = useMemo(() => { const files = useMemo(() => {
return data ?? []; return data?.data ?? [];
}, [data]); }, [data]);
const fileIdToIndex = useMemo(() => { const fileIdToIndex = useMemo(() => {
@@ -99,26 +106,54 @@ export function FolderScreen() {
}, []); }, []);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
const count = selectedIds.size; const ids = Array.from(selectedIds);
if (count === 0) return; if (ids.length === 0) return;
Alert.alert( const hasSynced = ids.some((id) => {
'Supprimer', const f = files.find((fi) => fi.id === id);
`Supprimer ${count === 1 ? 'ce fichier' : `ces ${count} fichiers`} ?`, return f?.syncStatus === 'synced';
[ });
const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`;
const options: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [
{ text: 'Annuler', style: 'cancel' }, { text: 'Annuler', style: 'cancel' },
{ ];
text: 'Supprimer', if (hasSynced) {
style: 'destructive', options.push({
text: 'Du device uniquement',
onPress: async () => { onPress: async () => {
for (const id of selectedIds) { const syncedIds = ids.filter((id) => {
await deleteFile.mutateAsync(id); const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced';
});
if (syncedIds.length > 0) {
await freeLocalSpace.mutateAsync(syncedIds);
} }
setSelectedIds(new Set()); setSelectedIds(new Set());
}, },
});
}
options.push({
text: 'Du device + serveur',
style: 'destructive',
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);
} else {
fileStore.deleteById(id);
}
if (f?.localUri) {
try { await deleteAsync(f.localUri, { idempotent: true }); } catch {}
}
downloadRegistry.remove(id);
}
await queryClient.invalidateQueries({ queryKey: ['files'] });
setSelectedIds(new Set());
}, },
] });
); Alert.alert('Supprimer', `Supprimer ${label} ?`, options);
}, [selectedIds, deleteFile]); }, [selectedIds, files, deleteFile, freeLocalSpace, queryClient]);
const handleItemPress = useCallback((file: UnifiedFileItem) => { const handleItemPress = useCallback((file: UnifiedFileItem) => {
if (selectionMode) { if (selectionMode) {
+52 -54
View File
@@ -4,10 +4,8 @@ import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useDeleteFile, useAddTags, useMoveFiles, useFolders } from '../hooks/useFiles'; import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFiles, useFreeLocalSpace } from '../hooks/useFiles';
import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles'; import { UnifiedFileItem, isFolder } from '../types';
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar'; import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail'; import { FileThumbnail } from '../components/FileThumbnail';
import { SettingsModal } from '../components/SettingsModal'; import { SettingsModal } from '../components/SettingsModal';
@@ -15,6 +13,13 @@ import { SyncStatusIcon } from '../components/SyncStatusIcon';
import { useSyncQueue } from '../hooks/useSyncQueue'; import { useSyncQueue } from '../hooks/useSyncQueue';
import { useAutoSync } from '../hooks/useAutoSync'; import { useAutoSync } from '../hooks/useAutoSync';
import { safDirectory, StoredFolder, SyncMode, SyncGlobalMode } from '../services/safDirectory'; import { safDirectory, StoredFolder, SyncMode, SyncGlobalMode } from '../services/safDirectory';
import { fileStore } from '../services/fileStore';
import { downloadRegistry } from '../services/downloadRegistry';
import { useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { useLocalFiles } from '../hooks/useLocalFiles';
import { deleteAsync } from 'expo-file-system/legacy';
const NUM_COLUMNS = 3; const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width; const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -157,9 +162,11 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
export function HomeScreen() { export function HomeScreen() {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { data, isLoading, error, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useUnifiedFiles(); const { data, isLoading, error } = useFiles();
const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
const deleteFile = useDeleteFile(); const deleteFile = useDeleteFile();
const freeLocalSpace = useFreeLocalSpace(); const freeLocalSpace = useFreeLocalSpace();
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true, tags: true }); const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true, tags: true });
const [keyboardOpen, setKeyboardOpen] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false);
@@ -203,7 +210,7 @@ export function HomeScreen() {
const selectionMode = selectedIds.size > 0; const selectionMode = selectedIds.size > 0;
const files = data ?? []; const files = data?.data ?? [];
const filteredFiles = useMemo( const filteredFiles = useMemo(
() => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files, () => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files,
@@ -233,55 +240,54 @@ export function HomeScreen() {
}, []); }, []);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
const count = selectedIds.size; const ids = Array.from(selectedIds);
if (count === 0) return; if (ids.length === 0) return;
const label = count === 1 ? 'ce fichier' : `ces ${count} fichiers`; const hasSynced = ids.some((id) => {
Alert.alert( const f = files.find((fi) => fi.id === id);
'Supprimer', return f?.syncStatus === 'synced';
`Supprimer ${label} ?`, });
[ const label = ids.length === 1 ? 'ce fichier' : `ces ${ids.length} fichiers`;
const options: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [
{ text: 'Annuler', style: 'cancel' }, { text: 'Annuler', style: 'cancel' },
{ ];
text: 'Supprimer', if (hasSynced) {
style: 'destructive', options.push({
text: 'Du device uniquement',
onPress: async () => { onPress: async () => {
const ids = Array.from(selectedIds);
for (const id of ids) {
await deleteFile.mutateAsync(id);
}
setSelectedIds(new Set());
},
},
]
);
}, [selectedIds, deleteFile]);
const handleFreeSpace = useCallback(() => {
const ids = Array.from(selectedIds);
const syncedIds = ids.filter((id) => { const syncedIds = ids.filter((id) => {
const f = files.find((fi) => fi.id === id); const f = files.find((fi) => fi.id === id);
return f?.syncStatus === 'synced'; return f?.syncStatus === 'synced';
}); });
if (syncedIds.length === 0) { if (syncedIds.length > 0) {
Alert.alert('Info', 'Aucun fichier avec copie locale sélectionné.');
return;
}
const label = syncedIds.length === 1 ? 'ce fichier' : `ces ${syncedIds.length} fichiers`;
Alert.alert(
'Libérer l\'espace',
`${label} sera supprimé de votre appareil mais restera disponible sur le serveur. Vous pourrez le re-télécharger ultérieurement.`,
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Libérer',
onPress: async () => {
await freeLocalSpace.mutateAsync(syncedIds); await freeLocalSpace.mutateAsync(syncedIds);
}
setSelectedIds(new Set()); setSelectedIds(new Set());
}, },
});
}
options.push({
text: 'Du device + serveur',
style: 'destructive',
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);
} else {
fileStore.deleteById(id);
}
if (f?.localUri) {
try { await deleteAsync(f.localUri, { idempotent: true }); } catch {}
}
downloadRegistry.remove(id);
}
await queryClient.invalidateQueries({ queryKey: ['files'] });
setSelectedIds(new Set());
}, },
] });
); Alert.alert('Supprimer', `Supprimer ${label} ?`, options);
}, [selectedIds, files, freeLocalSpace]); }, [selectedIds, files, deleteFile, freeLocalSpace, queryClient]);
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds);
@@ -542,13 +548,6 @@ export function HomeScreen() {
<MaterialIcons name="drive-file-move" size={18} color="#00897B" /> <MaterialIcons name="drive-file-move" size={18} color="#00897B" />
<Text style={[styles.chipText, { color: '#00897B' }]}>Déplacer</Text> <Text style={[styles.chipText, { color: '#00897B' }]}>Déplacer</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={[styles.selectionChip, styles.freeSpaceChip]}
onPress={handleFreeSpace}
>
<MaterialIcons name="free-cancellation" size={18} color="#FF8F00" />
<Text style={[styles.chipText, { color: '#FF8F00' }]}>Libérer</Text>
</TouchableOpacity>
</ScrollView> </ScrollView>
</View> </View>
) : ( ) : (
@@ -619,7 +618,7 @@ export function HomeScreen() {
<MaterialIcons name="home" size={20} color="#666" /> <MaterialIcons name="home" size={20} color="#666" />
<Text style={styles.folderOptionText}>Racine</Text> <Text style={styles.folderOptionText}>Racine</Text>
</TouchableOpacity> </TouchableOpacity>
{(foldersData?.data ?? []).map((folder) => ( {(foldersData ?? []).map((folder) => (
<TouchableOpacity <TouchableOpacity
key={folder.id} key={folder.id}
style={styles.folderOption} style={styles.folderOption}
@@ -840,7 +839,6 @@ const styles = StyleSheet.create({
tagChip: { backgroundColor: '#F3E5F5' }, tagChip: { backgroundColor: '#F3E5F5' },
folderChip: { backgroundColor: '#FFF3E0' }, folderChip: { backgroundColor: '#FFF3E0' },
moveChip: { backgroundColor: '#E0F2F1' }, moveChip: { backgroundColor: '#E0F2F1' },
freeSpaceChip: { backgroundColor: '#FFF8E1' },
folderOption: { folderOption: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
+3 -6
View File
@@ -9,10 +9,9 @@ import {
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { localFileRegistry } from '../services/localFileRegistry'; import { fileStore, FileRecord } from '../services/fileStore';
import { safDirectory } from '../services/safDirectory'; import { safDirectory } from '../services/safDirectory';
import { useSyncQueue } from '../hooks/useSyncQueue'; import { useSyncQueue } from '../hooks/useSyncQueue';
import { LocalFileEntry } from '../types';
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes === 0) return ''; if (bytes === 0) return '';
@@ -26,9 +25,7 @@ export function SyncDetailScreen() {
const { pendingCount, isSyncing, refresh } = useSyncQueue(); const { pendingCount, isSyncing, refresh } = useSyncQueue();
const pendingFiles = useMemo(() => { const pendingFiles = useMemo(() => {
return localFileRegistry.getAll().filter( return fileStore.getPendingSync();
(entry) => !entry.backendFileId && entry.syncStatus === 'local'
);
}, []); }, []);
const handleSyncAll = useCallback(() => { const handleSyncAll = useCallback(() => {
@@ -36,7 +33,7 @@ export function SyncDetailScreen() {
console.log(`[SyncDetail] sync demandé pour ${pendingFiles.length} fichiers`); console.log(`[SyncDetail] sync demandé pour ${pendingFiles.length} fichiers`);
}, [pendingFiles.length]); }, [pendingFiles.length]);
const renderItem = useCallback(({ item }: { item: LocalFileEntry }) => ( const renderItem = useCallback(({ item }: { item: FileRecord }) => (
<View style={styles.fileRow}> <View style={styles.fileRow}>
<MaterialIcons name="insert-drive-file" size={20} color="#999" /> <MaterialIcons name="insert-drive-file" size={20} color="#999" />
<View style={styles.fileInfo}> <View style={styles.fileInfo}>
+9 -9
View File
@@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { File, UploadType } from 'expo-file-system'; import { File, UploadType } from 'expo-file-system';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { safDirectory, StoredFolder } from '../services/safDirectory'; import { safDirectory, StoredFolder } from '../services/safDirectory';
import { localFileRegistry } from '../services/localFileRegistry'; import { fileStore } from '../services/fileStore';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError } from '../types'; import { ApiError } from '../types';
@@ -70,13 +70,13 @@ export function useAutoSync() {
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return; if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
const registry = localFileRegistry.getAll(); const registry = fileStore.getAllLocal();
let pendingFiles = registry.filter( let pendingFiles = registry.filter(
(entry) => !entry.backendFileId && entry.syncStatus === 'local' && entry.localUri (entry) => !entry.backendId && entry.syncStatus === 'local' && entry.localUri
); );
if (globalMode === 'auto') { if (globalMode === 'auto') {
// Mode auto global : tous les fichiers locaux sans backendFileId // Mode auto global : tous les fichiers locaux sans backendId
// (pas de filtre par dossier) // (pas de filtre par dossier)
} else { } else {
// Mode manuel : uniquement les fichiers des dossiers en mode auto // Mode manuel : uniquement les fichiers des dossiers en mode auto
@@ -85,7 +85,7 @@ export function useAutoSync() {
allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id) allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id)
); );
pendingFiles = pendingFiles.filter( pendingFiles = pendingFiles.filter(
(entry) => entry.folderId && autoFolderIds.has(entry.folderId) (entry) => entry.parentFileId && autoFolderIds.has(entry.parentFileId)
); );
} }
@@ -96,15 +96,15 @@ export function useAutoSync() {
for (const entry of pendingFiles) { for (const entry of pendingFiles) {
try { try {
const uploaded = await uploadFile({ const uploaded = await uploadFile({
uri: entry.localUri, uri: entry.localUri!,
type: entry.mimeType, type: entry.mimeType,
name: entry.name, name: entry.name,
}); });
localFileRegistry.register({ fileStore.updatePartial(entry.id, {
...entry, backendId: uploaded.id,
backendFileId: uploaded.id,
syncStatus: 'synced', syncStatus: 'synced',
source: 'synced',
}); });
} catch (err) { } catch (err) {
// upload failed, will retry on next cycle // upload failed, will retry on next cycle
-12
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import * as MediaLibrary from 'expo-media-library/legacy'; import * as MediaLibrary from 'expo-media-library/legacy';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { LocalFileEntry } from '../types';
import { safDirectory, type StoredFolder } from '../services/safDirectory'; import { safDirectory, type StoredFolder } from '../services/safDirectory';
import { downloadRegistry } from '../services/downloadRegistry'; import { downloadRegistry } from '../services/downloadRegistry';
import { useFileWatcher } from './useFileWatcher'; import { useFileWatcher } from './useFileWatcher';
@@ -238,14 +237,3 @@ export function useDeviceFiles() {
return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders }; return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders };
} }
export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry {
return {
id: deviceFile.id,
localUri: deviceFile.uri,
name: deviceFile.name,
mimeType: deviceFile.mimeType,
size: deviceFile.size,
syncStatus: 'local',
createdAt: deviceFile.createdAt,
};
}
+207 -23
View File
@@ -1,25 +1,108 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api'; import { ENDPOINTS } from '../constants/api';
import { FileItem, PaginatedResponse } from '../types'; import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types';
import { metadataCache } from '../services/metadataCache'; import { fileStore } from '../services/fileStore';
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
if (!record) return null;
return {
id: record.id,
backendFileId: 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,
parentFileId: record.parentFileId ?? undefined,
thumbnailUrl: record.thumbnailUrl ?? undefined,
isDeviceFile: record.source === 'local' && !record.backendId,
};
}
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getPaginated>): {
data: UnifiedFileItem[];
meta: { page: number; total: number };
} {
return {
data: records.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: records.total },
};
}
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 50) {
const queryKey = parentId
? ['files', parentId]
: ['files', 'root', page, limit];
export function useFiles(page: number = 1, limit: number = 20) {
return useQuery({ return useQuery({
queryKey: ['files', page, limit], queryKey,
queryFn: async () => { queryFn: async () => {
const res = await apiClient.get<PaginatedResponse<FileItem>>( if (parentId) {
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail` const backendRes = await apiClient.get<{ data: FileItem[] }>(
`/files/folders/${parentId}/files?thumbnail=thumbnail`,
); );
metadataCache.setFiles(res.data, res.meta.page, res.meta.total); fileStore.mergeFromBackend(
return res; backendRes.data.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
ocrText: f.ocrText,
tags: f.tags,
isFolder: f.isFolder,
parentFileId: f.parentFileId,
thumbnailUrl: f.thumbnailUrl,
})),
);
const children = fileStore.getChildrenByParent(parentId);
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: children.length },
};
}
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`,
);
fileStore.mergeFromBackend(
backendRes.data.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
ocrText: f.ocrText,
tags: f.tags,
isFolder: f.isFolder,
parentFileId: f.parentFileId,
thumbnailUrl: f.thumbnailUrl,
})),
);
const cached = fileStore.getPaginated(page, limit);
return recordsToUnifiedItems(cached);
}, },
initialData: () => { initialData: () => {
const cached = metadataCache.getFiles(); let records;
if (cached && cached.page === page) { if (parentId) {
return { data: cached.files, meta: { page: cached.page, total: cached.total } }; const children = fileStore.getChildrenByParent(parentId);
records = { files: children, total: children.length };
} else {
records = fileStore.getPaginated(page, limit);
} }
return undefined; if (records.files.length === 0) return undefined;
return recordsToUnifiedItems(records);
}, },
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -36,7 +119,10 @@ export function useFile(id: string) {
export function useFileImage(fileId: string) { export function useFileImage(fileId: string) {
return useQuery({ return useQuery({
queryKey: ['fileImage', fileId], queryKey: ['fileImage', fileId],
queryFn: () => apiClient.get<{ data: { id: string; name: string; url: string; size: number } }>(`${ENDPOINTS.FILE}/${fileId}`), queryFn: () =>
apiClient.get<{ data: { id: string; name: string; url: string; size: number } }>(
`${ENDPOINTS.FILE}/${fileId}`,
),
enabled: !!fileId, enabled: !!fileId,
}); });
} }
@@ -45,10 +131,13 @@ export function useDeleteFile() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: string) => apiClient.delete(`${ENDPOINTS.FILES}/${id}`), mutationFn: async (id: string) => {
const result = await apiClient.delete(`${ENDPOINTS.FILES}/${id}`);
fileStore.deleteByBackendId(id);
return result;
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] }); queryClient.invalidateQueries({ queryKey: ['files'] });
metadataCache.clear();
}, },
}); });
} }
@@ -61,7 +150,6 @@ export function useAddTags() {
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }), apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] }); queryClient.invalidateQueries({ queryKey: ['files'] });
metadataCache.clear();
}, },
}); });
} }
@@ -74,7 +162,6 @@ export function useMoveFiles() {
apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }), apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] }); queryClient.invalidateQueries({ queryKey: ['files'] });
metadataCache.clear();
}, },
}); });
} }
@@ -82,15 +169,112 @@ export function useMoveFiles() {
export function useFolders() { export function useFolders() {
return useQuery({ return useQuery({
queryKey: ['folders'], queryKey: ['folders'],
queryFn: () => apiClient.get<{ data: FileItem[] }>(ENDPOINTS.FOLDERS), queryFn: async () => {
const backendRes = await apiClient.get<{ data: FileItem[] }>(ENDPOINTS.FOLDERS);
fileStore.mergeFromBackend(
backendRes.data.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
ocrText: f.ocrText,
tags: f.tags,
isFolder: f.isFolder,
parentFileId: f.parentFileId,
thumbnailUrl: f.thumbnailUrl,
})),
);
return fileStore.getAllFolders();
},
initialData: () => {
const folders = fileStore.getAllFolders();
return folders.length > 0 ? folders : undefined;
},
staleTime: 60_000,
}); });
} }
export function useFilesByParent(parentId: string) { export function useFilesByParent(parentId: string) {
return useQuery({ return useFiles(parentId);
queryKey: ['files', 'parent', parentId], }
queryFn: () =>
apiClient.get<{ data: FileItem[] }>(`${ENDPOINTS.FOLDERS}/${parentId}/files?thumbnail=thumbnail`), export function useDownloadFile() {
enabled: !!parentId, const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: UnifiedFileItem): Promise<string> => {
if (file.syncStatus !== 'cloud') {
return file.localUri ?? '';
}
const res = await apiClient.get<{ data: { url: string; name: string } }>(
`/files/${file.backendFileId}`,
);
const downloadUrl = res.data.url;
const { downloadAsync, documentDirectory, makeDirectoryAsync } = await import('expo-file-system/legacy');
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
let hash = 0;
for (let i = 0; i < file.name.length; i++) {
hash = ((hash << 5) - hash + file.name.charCodeAt(i)) | 0;
}
const cacheKey = Math.abs(hash).toString(36);
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot) : '';
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
const result = await downloadAsync(downloadUrl, fileUri);
fileStore.upsert({
id: file.backendFileId ?? file.id,
backendId: file.backendFileId ?? file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced',
parentFileId: file.parentFileId ?? null,
isFolder: 0,
ocrText: file.ocrText ?? null,
thumbnailUrl: file.thumbnailUrl ?? null,
createdAt: file.createdAt,
updatedAt: file.updatedAt ?? file.createdAt,
lastSyncedAt: new Date().toISOString(),
tags: file.tags,
});
queryClient.invalidateQueries({ queryKey: ['files'] });
return result.uri;
},
});
}
export function useFreeLocalSpace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (fileIds: string[]) => {
const { deleteAsync } = await import('expo-file-system/legacy');
for (const fid of fileIds) {
const entry = fileStore.getByBackendId(fid);
if (!entry) continue;
if (entry.localUri) {
try {
await deleteAsync(entry.localUri, { idempotent: true });
} catch {}
}
fileStore.markAsCloudOnly(entry.id);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
}); });
} }
+32 -20
View File
@@ -1,7 +1,7 @@
import { useMemo, useEffect, useRef } from 'react'; import { useMemo, useEffect, useRef } from 'react';
import { useDeviceFiles } from './useDeviceFiles'; import { useDeviceFiles } from './useDeviceFiles';
import { localFileRegistry } from '../services/localFileRegistry'; import { fileStore } from '../services/fileStore';
import { LocalFileEntry } from '../types'; import { UnifiedFileItem } from '../types';
export function useLocalFiles() { export function useLocalFiles() {
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles(); const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
@@ -12,44 +12,56 @@ export function useLocalFiles() {
if (deviceFiles.length === lastDeviceCount.current) return; if (deviceFiles.length === lastDeviceCount.current) return;
lastDeviceCount.current = deviceFiles.length; lastDeviceCount.current = deviceFiles.length;
const newEntries: LocalFileEntry[] = []; fileStore.mergeFromDevice(
for (const df of deviceFiles) { deviceFiles.map((df) => ({
if (localFileRegistry.get(df.id)) continue;
newEntries.push({
id: df.id, id: df.id,
localUri: df.uri, uri: df.uri,
name: df.name, name: df.name,
mimeType: df.mimeType, mimeType: df.mimeType,
size: df.size, size: df.size,
syncStatus: 'local',
createdAt: df.createdAt, createdAt: df.createdAt,
folderId: df.folderId, folderId: df.folderId,
}); })),
} );
if (newEntries.length > 0) {
localFileRegistry.registerBatch(newEntries);
}
}, [deviceFiles]); }, [deviceFiles]);
const localFiles = useMemo(() => { const localFiles = useMemo(() => {
const registryEntries = localFileRegistry.getAll(); const registryEntries = fileStore.getAllLocal();
const merged = new Map<string, LocalFileEntry>(); const merged = new Map<string, UnifiedFileItem>();
for (const entry of registryEntries) { for (const entry of registryEntries) {
merged.set(entry.id, entry); merged.set(entry.id, {
id: entry.id,
backendFileId: entry.backendId ?? undefined,
name: entry.name,
mimeType: entry.mimeType,
size: entry.size,
createdAt: entry.createdAt,
source: entry.source as UnifiedFileItem['source'],
syncStatus: entry.syncStatus as UnifiedFileItem['syncStatus'],
localUri: entry.localUri ?? undefined,
tags: entry.tags ?? [],
isFolder: entry.isFolder === 1,
parentFileId: entry.parentFileId ?? undefined,
isDeviceFile: entry.source === 'local' && !entry.backendId,
});
} }
for (const df of deviceFiles) { for (const df of deviceFiles) {
if (!merged.has(df.id)) { if (!merged.has(df.id) && !fileStore.isDeleted(df.id)) {
merged.set(df.id, { merged.set(df.id, {
id: df.id, id: df.id,
localUri: df.uri,
name: df.name, name: df.name,
mimeType: df.mimeType, mimeType: df.mimeType,
size: df.size, size: df.size,
syncStatus: 'local',
createdAt: df.createdAt, createdAt: df.createdAt,
folderId: df.folderId, source: 'local',
syncStatus: 'local',
localUri: df.uri,
tags: [],
isFolder: false,
isDeviceFile: true,
parentFileId: df.folderId,
}); });
} }
} }
+15 -11
View File
@@ -1,10 +1,8 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy'; import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
import { useFiles } from './useFiles'; import { fileStore } from '../services/fileStore';
import { localFileRegistry } from '../services/localFileRegistry';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { LocalFileEntry } from '../types';
import { setIsSyncing } from './useSyncQueue'; import { setIsSyncing } from './useSyncQueue';
const SYNC_DIR = `${documentDirectory}synced-files/`; const SYNC_DIR = `${documentDirectory}synced-files/`;
@@ -30,9 +28,9 @@ export function usePullSync() {
}> }>('/files?page=1&limit=100&thumbnail=thumbnail'); }> }>('/files?page=1&limit=100&thumbnail=thumbnail');
const backendFiles = res.data ?? []; const backendFiles = res.data ?? [];
const registry = localFileRegistry.getAll(); const registry = fileStore.getAllSynced();
const existingBackendIds = new Set( const existingBackendIds = new Set(
registry.filter((e) => e.backendFileId).map((e) => e.backendFileId) registry.filter((e) => e.backendId).map((e) => e.backendId)
); );
let pulled = 0; let pulled = 0;
@@ -51,17 +49,23 @@ export function usePullSync() {
const result = await downloadAsync(downloadUrl, fileUri); const result = await downloadAsync(downloadUrl, fileUri);
const entry: LocalFileEntry = { fileStore.upsert({
id: `pull_${bf.id}`, id: bf.id,
backendFileId: bf.id, backendId: bf.id,
localUri: result.uri,
name: bf.name, name: bf.name,
mimeType: bf.mimeType, mimeType: bf.mimeType,
size: bf.size, size: bf.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced', syncStatus: 'synced',
parentFileId: null,
isFolder: 0,
ocrText: null,
thumbnailUrl: null,
createdAt: bf.createdAt, createdAt: bf.createdAt,
}; updatedAt: bf.createdAt,
localFileRegistry.register(entry); lastSyncedAt: new Date().toISOString(),
});
pulled++; pulled++;
} catch { } catch {
// skip individual file failures // skip individual file failures
+4 -7
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { createMMKV } from 'react-native-mmkv'; import { createMMKV } from 'react-native-mmkv';
import { localFileRegistry } from '../services/localFileRegistry'; import { fileStore } from '../services/fileStore';
import { safDirectory } from '../services/safDirectory'; import { safDirectory } from '../services/safDirectory';
import { useDeviceFiles } from './useDeviceFiles'; import { useDeviceFiles } from './useDeviceFiles';
@@ -28,19 +28,16 @@ export function useSyncQueue() {
return; return;
} }
const registry = localFileRegistry.getAll(); const registry = fileStore.getPendingSync();
let count = 0; let count = 0;
for (const entry of registry) { for (const entry of registry) {
if (entry.backendFileId) continue;
if (entry.syncStatus !== 'local') continue;
if (globalMode === 'auto') { if (globalMode === 'auto') {
count++; count++;
} else { } else {
// mode manuel : uniquement les fichiers des dossiers en mode auto // mode manuel : uniquement les fichiers des dossiers en mode auto
if (!entry.folderId) continue; if (!entry.parentFileId) continue;
const folder = safDirectory.getAll().find((f) => f.id === entry.folderId); const folder = safDirectory.getAll().find((f) => f.id === entry.parentFileId);
if (folder && folder.syncMode === 'auto') { if (folder && folder.syncMode === 'auto') {
count++; count++;
} }
-286
View File
@@ -1,286 +0,0 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } from 'expo-file-system/legacy';
import { useFiles } from './useFiles';
import { useLocalFiles } from './useLocalFiles';
import { localFileRegistry } from '../services/localFileRegistry';
import { thumbnailCache } from '../services/thumbnailCache';
import { apiClient } from '../api/client';
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
export interface UnifiedFileItem {
id: string;
backendFileId?: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt?: string;
syncStatus: SyncStatus;
localUri?: string;
ocrText?: string;
tags: Tag[];
isFolder: boolean;
parentFileId?: string;
url?: string;
thumbnailUrl?: string;
isDeviceFile?: boolean;
duplicateOf?: string;
}
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
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) : '';
}
function parseExpiresFromUrl(url: string): number {
try {
const u = new URL(url);
const expires = u.searchParams.get('expires');
if (expires) return Number(expires) * 1000;
} catch {}
return Date.now() + 50 * 60 * 1000;
}
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
const unifiedFiles = useMemo(() => {
const backendFiles = backendData?.data ?? [];
const merged = new Map<string, UnifiedFileItem>();
const registryAll = localFileRegistry.getAll();
const backendIdToLocal = new Map<string, LocalFileEntry>();
for (const entry of registryAll) {
if (entry.backendFileId) {
backendIdToLocal.set(entry.backendFileId, entry);
}
}
const nameSizeIndex = new Map<string, string>();
for (const bf of backendFiles) {
if (!bf.isFolder && bf.size > 0) {
nameSizeIndex.set(`${bf.name}::${bf.size}`, bf.id);
}
}
for (const bf of backendFiles) {
const localEntry = backendIdToLocal.get(bf.id);
let thumbUrl = bf.thumbnailUrl;
if (thumbUrl && bf.thumbnailUrl) {
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
} else {
const cached = thumbnailCache.get(bf.id);
if (cached) thumbUrl = cached;
}
merged.set(bf.id, {
id: bf.id,
backendFileId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt,
syncStatus: localEntry ? 'synced' : 'cloud',
localUri: localEntry?.localUri,
ocrText: bf.ocrText,
tags: bf.tags,
isFolder: bf.isFolder,
parentFileId: bf.parentFileId,
url: bf.url,
thumbnailUrl: thumbUrl,
});
}
for (const lf of localFiles) {
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
if (merged.has(lf.id)) continue;
if (!lf.folderId && lf.size > 0) {
const key = `${lf.name}::${lf.size}`;
const matchId = nameSizeIndex.get(key);
if (matchId) {
const existing = merged.get(matchId);
if (existing && !existing.localUri && lf.localUri) {
merged.set(matchId, { ...existing, localUri: lf.localUri, syncStatus: 'synced' });
}
continue;
}
}
merged.set(lf.id, {
id: lf.id,
backendFileId: lf.backendFileId,
name: lf.name,
mimeType: lf.mimeType,
size: lf.size,
createdAt: lf.createdAt,
syncStatus: 'local',
localUri: lf.localUri,
tags: lf.tags ?? [],
isFolder: false,
isDeviceFile: true,
});
}
return Array.from(merged.values()).sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA;
});
}, [backendData, localFiles]);
return {
data: unifiedFiles,
isLoading: backendLoading || localLoading,
error: backendError,
hasPermission,
requestPermission,
pickDirectory,
folders,
refreshFolders,
};
}
export function useUnifiedFilesByParent(parentId: string) {
const { data: backendData, isLoading: backendLoading } = useQuery({
queryKey: ['files', 'parent', parentId],
queryFn: () =>
apiClient.get<{ data: FileItem[] }>(`/files/folders/${parentId}/files?thumbnail=thumbnail`),
enabled: !!parentId,
});
const { localFiles, isLoading: localLoading } = useLocalFiles();
const unifiedFiles = useMemo(() => {
const backendFiles = backendData?.data ?? [];
const merged = new Map<string, UnifiedFileItem>();
const registryAll = localFileRegistry.getAll();
const backendIdToLocal = new Map<string, LocalFileEntry>();
for (const entry of registryAll) {
if (entry.backendFileId) backendIdToLocal.set(entry.backendFileId, entry);
}
for (const bf of backendFiles) {
const localEntry = backendIdToLocal.get(bf.id);
let thumbUrl = bf.thumbnailUrl;
if (thumbUrl && bf.thumbnailUrl) {
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
} else {
const cached = thumbnailCache.get(bf.id);
if (cached) thumbUrl = cached;
}
merged.set(bf.id, {
id: bf.id,
backendFileId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt,
syncStatus: localEntry ? 'synced' : 'cloud',
localUri: localEntry?.localUri,
ocrText: bf.ocrText,
tags: bf.tags,
isFolder: bf.isFolder,
parentFileId: bf.parentFileId,
url: bf.url,
thumbnailUrl: thumbUrl,
});
}
return Array.from(merged.values()).sort((a, b) => {
if (a.isFolder && !b.isFolder) return -1;
if (!a.isFolder && b.isFolder) return 1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [backendData, localFiles]);
return {
data: unifiedFiles,
isLoading: backendLoading || localLoading,
};
}
export function useDownloadFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: UnifiedFileItem): Promise<string> => {
if (file.syncStatus !== 'cloud') {
return file.localUri ?? '';
}
const res = await apiClient.get<{ data: { url: string; name: string } }>(
`/files/${file.backendFileId}`
);
const downloadUrl = res.data.url;
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
const cacheKey = getCacheKey(file.name);
const ext = getExtension(file.name);
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
const result = await downloadAsync(downloadUrl, fileUri);
const entry: LocalFileEntry = {
id: `local_${file.backendFileId}`,
backendFileId: file.backendFileId,
localUri: result.uri,
name: file.name,
mimeType: file.mimeType,
size: file.size,
syncStatus: 'synced',
createdAt: file.createdAt,
tags: file.tags,
};
localFileRegistry.register(entry);
queryClient.invalidateQueries({ queryKey: ['files'] });
return result.uri;
},
});
}
export function useFreeLocalSpace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (fileIds: string[]) => {
for (const fid of fileIds) {
const entry = localFileRegistry.get(fid);
if (!entry) continue;
if (entry.localUri) {
try {
await deleteAsync(entry.localUri, { idempotent: true });
} catch {}
}
localFileRegistry.markAsCloudOnly(fid);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
});
}
+1686 -8
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -7,7 +7,10 @@
"@react-native-community/netinfo": "12.0.1", "@react-native-community/netinfo": "12.0.1",
"@react-navigation/native": "^7.3.8", "@react-navigation/native": "^7.3.8",
"@react-navigation/native-stack": "^7.17.10", "@react-navigation/native-stack": "^7.17.10",
"@tanstack/query-async-storage-persister": "^5.101.4",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.4",
"drizzle-orm": "^0.45.2",
"expo": "~57.0.4", "expo": "~57.0.4",
"expo-document-picker": "~57.0.0", "expo-document-picker": "~57.0.0",
"expo-file-system": "~57.0.0", "expo-file-system": "~57.0.0",
@@ -16,6 +19,7 @@
"expo-media-library": "~57.0.3", "expo-media-library": "~57.0.3",
"expo-print": "~57.0.0", "expo-print": "~57.0.0",
"expo-secure-store": "~57.0.0", "expo-secure-store": "~57.0.0",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
@@ -31,6 +35,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"drizzle-kit": "^0.31.10",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"scripts": { "scripts": {
+433
View File
@@ -0,0 +1,433 @@
import { drizzle } from 'drizzle-orm/expo-sqlite';
import * as SQLite from 'expo-sqlite';
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';
let _db: ReturnType<typeof drizzle> | null = null;
let _sqliteDb: SQLite.SQLiteDatabase | null = null;
export function initDB() {
if (_db) return _db;
_sqliteDb = SQLite.openDatabaseSync(DB_NAME);
_sqliteDb.execSync('PRAGMA journal_mode = WAL;');
_sqliteDb.execSync('PRAGMA foreign_keys = ON;');
_db = drizzle(_sqliteDb);
_sqliteDb.execSync(`
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
backend_id TEXT,
name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'cloud',
local_uri TEXT,
sync_status TEXT NOT NULL DEFAULT 'cloud',
parent_file_id TEXT,
is_folder INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT,
thumbnail_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_synced_at TEXT
);
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'
);
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);
CREATE TABLE IF NOT EXISTS deleted_files (
id TEXT PRIMARY KEY,
deleted_at TEXT NOT NULL
);
`);
return _db;
}
function getDb() {
if (!_db) initDB();
return _db!;
}
export type FileRecord = {
id: string;
backendId: string | null;
name: string;
mimeType: string;
size: number;
source: string;
localUri: string | null;
syncStatus: string;
parentFileId: string | null;
isFolder: number;
ocrText: string | null;
thumbnailUrl: string | null;
createdAt: string;
updatedAt: string;
lastSyncedAt: string | null;
tags?: Tag[];
};
type FileRow = typeof files.$inferSelect;
type TagRow = typeof fileTags.$inferSelect;
function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord {
return {
id: row.id,
backendId: row.backendId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
source: row.source,
localUri: row.localUri,
syncStatus: row.syncStatus,
parentFileId: row.parentFileId,
isFolder: row.isFolder,
ocrText: row.ocrText,
thumbnailUrl: row.thumbnailUrl,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastSyncedAt: row.lastSyncedAt,
tags,
};
}
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 }));
}
function setTagsForFile(fileId: string, tags: Tag[]) {
const d = getDb();
d.delete(fileTags).where(eq(fileTags.fileId, fileId)).run();
if (tags.length === 0) return;
d.insert(fileTags).values(
tags.map((t) => ({
id: `${fileId}_${t.id || t.tag_name}`,
fileId,
tagName: t.tag_name,
tagType: t.tag_type,
})),
).run();
}
function upsertRow(file: FileRecord) {
const d = getDb();
d.insert(files).values({
id: file.id,
backendId: file.backendId,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: file.source,
localUri: file.localUri,
syncStatus: file.syncStatus,
parentFileId: file.parentFileId,
isFolder: file.isFolder,
ocrText: file.ocrText,
thumbnailUrl: file.thumbnailUrl,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
lastSyncedAt: file.lastSyncedAt,
}).onConflictDoUpdate({
target: files.id,
set: {
backendId: file.backendId,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: file.source,
localUri: file.localUri,
syncStatus: file.syncStatus,
parentFileId: file.parentFileId,
isFolder: file.isFolder,
ocrText: file.ocrText,
thumbnailUrl: file.thumbnailUrl,
updatedAt: file.updatedAt,
lastSyncedAt: file.lastSyncedAt,
},
}).run();
}
export const fileStore = {
initDB,
upsert(file: FileRecord) {
upsertRow(file);
if (file.tags) setTagsForFile(file.id, file.tags);
},
upsertBatch(fileList: FileRecord[]) {
const d = getDb();
for (const file of fileList) {
upsertRow(file);
if (file.tags) setTagsForFile(file.id, file.tags);
}
},
getById(id: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.id, id)).get();
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();
if (!row) return null;
return rowToRecord(row, getTagsForFile(row.id));
},
getRootFolders(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.isFolder, 1), isNull(files.parentFileId)))
.orderBy(asc(files.name))
.all();
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))
.orderBy(desc(files.isFolder), desc(files.createdAt))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } {
const d = getDb();
const offset = (page - 1) * limit;
const countRow = d.select({ count: sql<number>`count(*)` })
.from(files)
.where(and(isNull(files.parentFileId), 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)))
.orderBy(desc(files.createdAt))
.limit(limit)
.offset(offset)
.all();
return {
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
total,
};
},
getAllFolders(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.isFolder, 1))
.orderBy(asc(files.name))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
search(query: string): FileRecord[] {
const d = getDb();
const pattern = `%${query}%`;
const rows = d.select().from(files)
.where(or(like(files.name, pattern), like(files.ocrText, pattern)))
.orderBy(desc(files.createdAt))
.limit(100)
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
mergeFromBackend(backendFiles: Array<{
id: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt?: string;
ocrText?: string;
tags?: Tag[];
isFolder: boolean;
parentFileId?: string;
thumbnailUrl?: 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 source = existing && existing.localUri ? 'synced' : 'cloud';
const syncStatus = existing && existing.localUri
? (existing.syncStatus === 'cloud' ? 'synced' : existing.syncStatus)
: 'cloud';
upsertRow({
id: bf.id,
backendId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
source,
localUri: existing?.localUri ?? null,
syncStatus,
parentFileId: bf.parentFileId ?? null,
isFolder: bf.isFolder ? 1 : 0,
ocrText: bf.ocrText ?? null,
thumbnailUrl: bf.thumbnailUrl ?? null,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt ?? now,
lastSyncedAt: now,
});
if (bf.tags) setTagsForFile(bf.id, bf.tags);
}
});
},
mergeFromDevice(deviceFiles: Array<{
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
folderId?: string;
}>) {
const d = getDb();
const now = new Date().toISOString();
d.transaction(() => {
for (const df of deviceFiles) {
if (this.isDeleted(df.id)) continue;
const existing = d.select().from(files).where(eq(files.id, df.id)).get();
if (existing) continue;
upsertRow({
id: df.id,
backendId: null,
name: df.name,
mimeType: df.mimeType,
size: df.size,
source: 'local',
localUri: df.uri,
syncStatus: 'local',
parentFileId: df.folderId ?? null,
isFolder: 0,
ocrText: null,
thumbnailUrl: null,
createdAt: df.createdAt,
updatedAt: now,
lastSyncedAt: null,
});
}
});
},
updatePartial(id: string, updates: Partial<FileRecord>) {
const d = getDb();
const setFields: Record<string, unknown> = {};
if (updates.backendId !== undefined) setFields.backendId = updates.backendId;
if (updates.syncStatus !== undefined) setFields.syncStatus = updates.syncStatus;
if (updates.localUri !== undefined) setFields.localUri = updates.localUri;
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.name !== undefined) setFields.name = updates.name;
setFields.updatedAt = new Date().toISOString();
d.update(files).set(setFields).where(eq(files.id, id)).run();
},
updateSyncStatus(id: string, syncStatus: string) {
this.updatePartial(id, { syncStatus });
},
markAsCloudOnly(id: string) {
this.updatePartial(id, { syncStatus: 'cloud', localUri: null, source: 'cloud' });
},
setThumbnailUrl(backendId: string, thumbnailUrl: string) {
const d = getDb();
d.update(files).set({ thumbnailUrl, updatedAt: new Date().toISOString() })
.where(eq(files.backendId, backendId)).run();
},
markDeleted(id: string) {
const d = getDb();
d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() })
.onConflictDoUpdate({ target: deletedFiles.id, set: { deletedAt: new Date().toISOString() } })
.run();
},
isDeleted(id: string): boolean {
const d = getDb();
const row = d.select().from(deletedFiles).where(eq(deletedFiles.id, id)).get();
return !!row;
},
deleteById(id: string) {
const d = getDb();
this.markDeleted(id);
d.delete(files).where(eq(files.id, id)).run();
},
deleteByBackendId(backendId: string) {
const d = getDb();
const row = d.select().from(files).where(eq(files.backendId, backendId)).get();
if (row) this.markDeleted(row.id);
d.delete(files).where(eq(files.backendId, backendId)).run();
},
clear() {
const d = getDb();
d.delete(fileTags).run();
d.delete(files).run();
},
count(): number {
const d = getDb();
const row = d.select({ count: sql<number>`count(*)` }).from(files).get();
return row?.count ?? 0;
},
getAllLocal(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(or(eq(files.source, 'local'), eq(files.source, 'synced')))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getAllSynced(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(eq(files.source, 'synced'))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
getPendingSync(): FileRecord[] {
const d = getDb();
const rows = d.select().from(files)
.where(and(eq(files.syncStatus, 'local'), sql`${files.backendId} IS NULL`))
.all();
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
},
};
+112
View File
@@ -0,0 +1,112 @@
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<string, {
id: string;
backendFileId?: string;
localUri: string;
name: string;
mimeType: string;
size: number;
syncStatus: SyncStatus;
createdAt: string;
tags?: Tag[];
folderId?: string;
}>;
}
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 {}
}
+47
View File
@@ -0,0 +1,47 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const files = sqliteTable(
'files',
{
id: text('id').primaryKey(),
backendId: text('backend_id'),
name: text('name').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
source: text('source').notNull().default('cloud'),
localUri: text('local_uri'),
syncStatus: text('sync_status').notNull().default('cloud'),
parentFileId: text('parent_file_id'),
isFolder: integer('is_folder').notNull().default(0),
ocrText: text('ocr_text'),
thumbnailUrl: text('thumbnail_url'),
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_source').on(t.source),
index('idx_files_is_folder').on(t.isFolder),
index('idx_files_sync_status').on(t.syncStatus),
],
);
export const fileTags = sqliteTable(
'file_tags',
{
id: text('id').primaryKey(),
fileId: text('file_id')
.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)],
);
export const deletedFiles = sqliteTable('deleted_files', {
id: text('id').primaryKey(),
deletedAt: text('deleted_at').notNull(),
});
-146
View File
@@ -1,146 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { LocalFileEntry, SyncStatus } from '../types';
const storage = createMMKV({ id: 'vaultdrop-local-files' });
const BLOB_KEY = 'local_files_v2';
const LEGACY_INDEX_KEY = 'local_files_index';
interface RegistryBlob {
entries: Record<string, LocalFileEntry>;
backendIndex: Record<string, string>;
}
let memoryCache: RegistryBlob | null = null;
function loadBlob(): RegistryBlob {
if (memoryCache) return memoryCache;
const raw = storage.getString(BLOB_KEY);
if (raw) {
memoryCache = JSON.parse(raw) as RegistryBlob;
return memoryCache;
}
memoryCache = migrateFromLegacy();
saveBlob(memoryCache);
return memoryCache;
}
function saveBlob(blob: RegistryBlob) {
memoryCache = blob;
storage.set(BLOB_KEY, JSON.stringify(blob));
}
function migrateFromLegacy(): RegistryBlob {
const blob: RegistryBlob = { entries: {}, backendIndex: {} };
const rawIndex = storage.getString(LEGACY_INDEX_KEY);
if (!rawIndex) return blob;
const ids: string[] = JSON.parse(rawIndex);
for (const id of ids) {
const raw = storage.getString(`local_file_${id}`);
if (!raw) continue;
const entry: LocalFileEntry = JSON.parse(raw);
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
blob.backendIndex[entry.backendFileId] = entry.id;
}
}
storage.remove(LEGACY_INDEX_KEY);
for (const id of ids) {
storage.remove(`local_file_${id}`);
}
return blob;
}
export const localFileRegistry = {
register(entry: LocalFileEntry) {
const blob = loadBlob();
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
blob.backendIndex[entry.backendFileId] = entry.id;
}
saveBlob(blob);
},
registerBatch(entries: LocalFileEntry[]) {
if (entries.length === 0) return;
const blob = loadBlob();
for (const entry of entries) {
blob.entries[entry.id] = entry;
if (entry.backendFileId) {
blob.backendIndex[entry.backendFileId] = entry.id;
}
}
saveBlob(blob);
},
get(id: string): LocalFileEntry | undefined {
return loadBlob().entries[id];
},
getByBackendId(backendId: string): LocalFileEntry | undefined {
const blob = loadBlob();
const entryId = blob.backendIndex[backendId];
if (!entryId) return undefined;
return blob.entries[entryId];
},
getAll(): LocalFileEntry[] {
const blob = loadBlob();
return Object.values(blob.entries);
},
update(id: string, updates: Partial<LocalFileEntry>) {
const blob = loadBlob();
const existing = blob.entries[id];
if (!existing) return;
if (existing.backendFileId && updates.backendFileId === undefined && updates.syncStatus === 'cloud') {
delete blob.backendIndex[existing.backendFileId];
}
const updated = { ...existing, ...updates };
blob.entries[id] = updated;
if (updated.backendFileId) {
blob.backendIndex[updated.backendFileId] = id;
}
saveBlob(blob);
},
updateSyncStatus(id: string, syncStatus: SyncStatus) {
this.update(id, { syncStatus });
},
markAsCloudOnly(id: string) {
this.update(id, { syncStatus: 'cloud', localUri: '' });
},
remove(id: string) {
const blob = loadBlob();
const entry = blob.entries[id];
if (entry?.backendFileId) {
delete blob.backendIndex[entry.backendFileId];
}
delete blob.entries[id];
saveBlob(blob);
},
removeByBackendId(backendId: string) {
const blob = loadBlob();
const entryId = blob.backendIndex[backendId];
if (entryId) {
delete blob.entries[entryId];
delete blob.backendIndex[backendId];
saveBlob(blob);
}
},
count(): number {
return Object.keys(loadBlob().entries).length;
},
};
-44
View File
@@ -1,44 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
import { FileItem } from '../types';
const storage = createMMKV({ id: 'vaultdrop-metadata' });
const FILES_KEY = 'backend_files_cache';
const UPDATED_AT_KEY = 'cache_updated_at';
const STALE_MS = 5 * 60 * 1000;
interface CachedFiles {
files: FileItem[];
page: number;
total: number;
}
export const metadataCache = {
getFiles(): CachedFiles | null {
const raw = storage.getString(FILES_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as CachedFiles;
} catch {
return null;
}
},
setFiles(files: FileItem[], page: number, total: number) {
const data: CachedFiles = { files, page, total };
storage.set(FILES_KEY, JSON.stringify(data));
storage.set(UPDATED_AT_KEY, Date.now());
},
isStale(): boolean {
const raw = storage.getString(UPDATED_AT_KEY);
if (!raw) return true;
const updatedAt = Number(raw);
return Date.now() - updatedAt > STALE_MS;
},
clear() {
storage.remove(FILES_KEY);
storage.remove(UPDATED_AT_KEY);
},
};
+20
View File
@@ -0,0 +1,20 @@
import { createMMKV } from 'react-native-mmkv';
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
const storage = createMMKV({ id: 'vaultdrop-query-cache' });
export function createMMKVPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
storage.set('query-cache', JSON.stringify(client));
},
restoreClient: async () => {
const raw = storage.getString('query-cache');
if (!raw) return undefined;
return JSON.parse(raw) as PersistedClient;
},
removeClient: async () => {
storage.remove('query-cache');
},
};
}
-65
View File
@@ -1,65 +0,0 @@
import { createMMKV } from 'react-native-mmkv';
const storage = createMMKV({ id: 'vaultdrop-thumbnails' });
const BLOB_KEY = 'thumbnail_urls';
const STALE_MS = 50 * 60 * 1000;
interface ThumbnailCacheEntry {
url: string;
expiresAt: number;
}
let memoryCache: Record<string, ThumbnailCacheEntry> | null = null;
function loadCache(): Record<string, ThumbnailCacheEntry> {
if (memoryCache) return memoryCache;
const raw = storage.getString(BLOB_KEY);
memoryCache = raw ? JSON.parse(raw) : {};
return memoryCache!;
}
function saveCache(cache: Record<string, ThumbnailCacheEntry>) {
memoryCache = cache;
storage.set(BLOB_KEY, JSON.stringify(cache));
}
export const thumbnailCache = {
get(fileId: string): string | null {
const cache = loadCache();
const entry = cache[fileId];
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
delete cache[fileId];
saveCache(cache);
return null;
}
return entry.url;
},
set(fileId: string, url: string, expiresAt: number) {
const cache = loadCache();
cache[fileId] = { url, expiresAt };
saveCache(cache);
},
setBatch(entries: Array<{ fileId: string; url: string; expiresAt: number }>) {
if (entries.length === 0) return;
const cache = loadCache();
for (const e of entries) {
cache[e.fileId] = { url: e.url, expiresAt: e.expiresAt };
}
saveCache(cache);
},
remove(fileId: string) {
const cache = loadCache();
delete cache[fileId];
saveCache(cache);
},
clear() {
memoryCache = {};
storage.remove(BLOB_KEY);
},
};
+10 -16
View File
@@ -8,13 +8,17 @@ export interface Thumbnail {
mimeType: string; mimeType: string;
} }
export interface FileItem { export interface UnifiedFileItem {
id: string; id: string;
backendFileId?: string;
name: string; name: string;
mimeType: string; mimeType: string;
size: number; size: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt?: string;
source: 'cloud' | 'local' | 'synced';
syncStatus: SyncStatus;
localUri?: string;
ocrText?: string; ocrText?: string;
tags: Tag[]; tags: Tag[];
isFolder: boolean; isFolder: boolean;
@@ -22,9 +26,12 @@ export interface FileItem {
url?: string; url?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnails?: Thumbnail[]; thumbnails?: Thumbnail[];
isDeviceFile?: boolean;
} }
export function isFolder(file: FileItem | { isFolder: boolean }): boolean { export type FileItem = UnifiedFileItem;
export function isFolder(file: UnifiedFileItem | { isFolder: boolean }): boolean {
return file.isFolder; return file.isFolder;
} }
@@ -118,16 +125,3 @@ export interface RefreshResponse {
} }
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict'; export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
export interface LocalFileEntry {
id: string;
backendFileId?: string;
localUri: string;
name: string;
mimeType: string;
size: number;
syncStatus: SyncStatus;
createdAt: string;
tags?: Tag[];
folderId?: string;
}