migrate mobile with new endpoints

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