sqlite database
This commit is contained in:
+24
-4
@@ -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
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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'] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Generated
+1686
-8
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user