diff --git a/mobile/App.tsx b/mobile/App.tsx index cca7ca6..9304cf6 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { StatusBar } from 'expo-status-bar'; import { ActivityIndicator, View } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NavigationContainer } from '@react-navigation/native'; 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 { LoginScreen } from './app/login'; import { RegisterScreen } from './app/register'; @@ -20,9 +21,22 @@ import { FolderScreen } from './app/folder'; import { OnboardingScreen } from './app/onboarding'; import { SyncDetailScreen } from './app/sync-detail'; 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 queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1000 * 60 * 60 * 24, // 24h + }, + }, +}); +const persister = createMMKVPersister(); function AppNavigator() { const { user, isLoading } = useAuth(); @@ -83,12 +97,18 @@ function AppNavigator() { export default function App() { return ( - + - + ); } diff --git a/mobile/app.json b/mobile/app.json index e4666b8..811a563 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -45,7 +45,8 @@ "videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser." } ], - "expo-image" + "expo-image", + "expo-sqlite" ] } } diff --git a/mobile/app/file-detail.tsx b/mobile/app/file-detail.tsx index b8b956f..4c3621b 100644 --- a/mobile/app/file-detail.tsx +++ b/mobile/app/file-detail.tsx @@ -14,9 +14,8 @@ import { } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { RouteProp, useRoute } from '@react-navigation/native'; -import { useFile, useFileImage } from '../hooks/useFiles'; -import { useDownloadFile } from '../hooks/useUnifiedFiles'; -import { localFileRegistry } from '../services/localFileRegistry'; +import { useFile, useFileImage, useDownloadFile } from '../hooks/useFiles'; +import { fileStore } from '../services/fileStore'; import { TagChip } from '../components/TagChip'; import { FileThumbnail } from '../components/FileThumbnail'; 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 file = fileData as any; - const localEntry = localFileRegistry.getByBackendId(fileId); + const localEntry = fileStore.getByBackendId(fileId); const syncStatus: SyncStatus = isDevice ? 'local' : localEntry @@ -77,6 +76,7 @@ function DetailItem({ fileId, deviceFile, onSelectImage }: { fileId: string; dev mimeType: file.mimeType ?? 'application/octet-stream', size: file.data?.size ?? 0, createdAt: file.createdAt ?? new Date().toISOString(), + source: 'cloud', syncStatus: 'cloud', tags: file.data?.tags ?? [], isFolder: false, diff --git a/mobile/app/folder.tsx b/mobile/app/folder.tsx index 5ab84b2..75586bf 100644 --- a/mobile/app/folder.tsx +++ b/mobile/app/folder.tsx @@ -3,11 +3,16 @@ 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 } from '../hooks/useFiles'; -import { useUnifiedFilesByParent } from '../hooks/useUnifiedFiles'; -import { UnifiedFileItem } from '../hooks/useUnifiedFiles'; +import { useDeleteFile, useFileImage, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; +import { UnifiedFileItem } from '../types'; import { isFolder } from '../types'; 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 SCREEN_WIDTH = Dimensions.get('window').width; @@ -65,8 +70,10 @@ export function FolderScreen() { const route = useRoute(); const navigation = useNavigation(); const { folderId, folderName } = route.params; - const { data, isLoading } = useUnifiedFilesByParent(folderId); + const { data, isLoading } = useFiles(folderId); const deleteFile = useDeleteFile(); + const freeLocalSpace = useFreeLocalSpace(); + const queryClient = useQueryClient(); const [selectedIds, setSelectedIds] = useState>(new Set()); const selectionMode = selectedIds.size > 0; @@ -76,7 +83,7 @@ export function FolderScreen() { }, [navigation, folderName]); const files = useMemo(() => { - return data ?? []; + return data?.data ?? []; }, [data]); const fileIdToIndex = useMemo(() => { @@ -99,26 +106,54 @@ export function FolderScreen() { }, []); const handleDelete = useCallback(() => { - const count = selectedIds.size; - if (count === 0) return; - Alert.alert( - 'Supprimer', - `Supprimer ${count === 1 ? 'ce fichier' : `ces ${count} fichiers`} ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { - text: 'Supprimer', - style: 'destructive', - onPress: async () => { - for (const id of selectedIds) { - await deleteFile.mutateAsync(id); - } - setSelectedIds(new Set()); - }, + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + const hasSynced = ids.some((id) => { + const f = files.find((fi) => fi.id === id); + 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' }, + ]; + if (hasSynced) { + options.push({ + text: 'Du device uniquement', + onPress: async () => { + const syncedIds = ids.filter((id) => { + const f = files.find((fi) => fi.id === id); + return f?.syncStatus === 'synced'; + }); + if (syncedIds.length > 0) { + await freeLocalSpace.mutateAsync(syncedIds); + } + setSelectedIds(new Set()); }, - ] - ); - }, [selectedIds, deleteFile]); + }); + } + 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, deleteFile, freeLocalSpace, queryClient]); const handleItemPress = useCallback((file: UnifiedFileItem) => { if (selectionMode) { diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index f4794a2..4354238 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -4,10 +4,8 @@ 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 } from '../hooks/useFiles'; -import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles'; -import { UnifiedFileItem } from '../hooks/useUnifiedFiles'; -import { FileItem, isFolder } from '../types'; +import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFiles, useFreeLocalSpace } from '../hooks/useFiles'; +import { UnifiedFileItem, isFolder } from '../types'; import { SearchBar, SearchFilters } from '../components/SearchBar'; import { FileThumbnail } from '../components/FileThumbnail'; import { SettingsModal } from '../components/SettingsModal'; @@ -15,6 +13,13 @@ import { SyncStatusIcon } from '../components/SyncStatusIcon'; import { useSyncQueue } from '../hooks/useSyncQueue'; import { useAutoSync } from '../hooks/useAutoSync'; 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 SCREEN_WIDTH = Dimensions.get('window').width; @@ -157,9 +162,11 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte export function HomeScreen() { const navigation = useNavigation(); 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 freeLocalSpace = useFreeLocalSpace(); + const queryClient = useQueryClient(); const [searchQuery, setSearchQuery] = useState(''); const [filters, setFilters] = useState({ name: true, ocrText: true, tags: true }); const [keyboardOpen, setKeyboardOpen] = useState(false); @@ -203,7 +210,7 @@ export function HomeScreen() { const selectionMode = selectedIds.size > 0; - const files = data ?? []; + const files = data?.data ?? []; const filteredFiles = useMemo( () => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files, @@ -233,55 +240,54 @@ export function HomeScreen() { }, []); const handleDelete = useCallback(() => { - const count = selectedIds.size; - if (count === 0) return; - const label = count === 1 ? 'ce fichier' : `ces ${count} fichiers`; - Alert.alert( - 'Supprimer', - `Supprimer ${label} ?`, - [ - { text: 'Annuler', style: 'cancel' }, - { - text: 'Supprimer', - style: 'destructive', - 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) => { + if (ids.length === 0) return; + const hasSynced = ids.some((id) => { const f = files.find((fi) => fi.id === id); return f?.syncStatus === 'synced'; }); - 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 () => { + 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' }, + ]; + if (hasSynced) { + options.push({ + text: 'Du device uniquement', + onPress: async () => { + const syncedIds = ids.filter((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()); }, - ] - ); - }, [selectedIds, files, freeLocalSpace]); + }); + } + 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, deleteFile, freeLocalSpace, queryClient]); const handleEdit = useCallback(() => { const ids = Array.from(selectedIds); @@ -542,13 +548,6 @@ export function HomeScreen() { Déplacer - - - Libérer - ) : ( @@ -619,7 +618,7 @@ export function HomeScreen() { Racine - {(foldersData?.data ?? []).map((folder) => ( + {(foldersData ?? []).map((folder) => ( { - return localFileRegistry.getAll().filter( - (entry) => !entry.backendFileId && entry.syncStatus === 'local' - ); + return fileStore.getPendingSync(); }, []); const handleSyncAll = useCallback(() => { @@ -36,7 +33,7 @@ export function SyncDetailScreen() { console.log(`[SyncDetail] sync demandé pour ${pendingFiles.length} fichiers`); }, [pendingFiles.length]); - const renderItem = useCallback(({ item }: { item: LocalFileEntry }) => ( + const renderItem = useCallback(({ item }: { item: FileRecord }) => ( diff --git a/mobile/hooks/useAutoSync.ts b/mobile/hooks/useAutoSync.ts index 0ea00b2..4171f98 100644 --- a/mobile/hooks/useAutoSync.ts +++ b/mobile/hooks/useAutoSync.ts @@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { File, UploadType } from 'expo-file-system'; import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; import { safDirectory, StoredFolder } from '../services/safDirectory'; -import { localFileRegistry } from '../services/localFileRegistry'; +import { fileStore } from '../services/fileStore'; import { apiClient } from '../api/client'; import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { ApiError } from '../types'; @@ -70,13 +70,13 @@ export function useAutoSync() { if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return; - const registry = localFileRegistry.getAll(); + const registry = fileStore.getAllLocal(); let pendingFiles = registry.filter( - (entry) => !entry.backendFileId && entry.syncStatus === 'local' && entry.localUri + (entry) => !entry.backendId && entry.syncStatus === 'local' && entry.localUri ); 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) } else { // 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) ); 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) { try { const uploaded = await uploadFile({ - uri: entry.localUri, + uri: entry.localUri!, type: entry.mimeType, name: entry.name, }); - localFileRegistry.register({ - ...entry, - backendFileId: uploaded.id, + fileStore.updatePartial(entry.id, { + backendId: uploaded.id, syncStatus: 'synced', + source: 'synced', }); } catch (err) { // upload failed, will retry on next cycle diff --git a/mobile/hooks/useDeviceFiles.ts b/mobile/hooks/useDeviceFiles.ts index 1381d86..578f0f7 100644 --- a/mobile/hooks/useDeviceFiles.ts +++ b/mobile/hooks/useDeviceFiles.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import * as MediaLibrary from 'expo-media-library/legacy'; import * as FileSystem from 'expo-file-system/legacy'; -import { LocalFileEntry } from '../types'; import { safDirectory, type StoredFolder } from '../services/safDirectory'; import { downloadRegistry } from '../services/downloadRegistry'; import { useFileWatcher } from './useFileWatcher'; @@ -238,14 +237,3 @@ export function useDeviceFiles() { 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, - }; -} diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts index a3eb217..38d41e1 100644 --- a/mobile/hooks/useFiles.ts +++ b/mobile/hooks/useFiles.ts @@ -1,25 +1,108 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../api/client'; import { ENDPOINTS } from '../constants/api'; -import { FileItem, PaginatedResponse } from '../types'; -import { metadataCache } from '../services/metadataCache'; +import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types'; +import { fileStore } from '../services/fileStore'; + +function recordToUnifiedItem(record: ReturnType): 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): { + 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({ - queryKey: ['files', page, limit], + queryKey, queryFn: async () => { - const res = await apiClient.get>( - `${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail` + if (parentId) { + const backendRes = await apiClient.get<{ data: FileItem[] }>( + `/files/folders/${parentId}/files?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 children = fileStore.getChildrenByParent(parentId); + return { + data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean), + meta: { page: 0, total: children.length }, + }; + } + + const backendRes = await apiClient.get>( + `${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`, ); - metadataCache.setFiles(res.data, res.meta.page, res.meta.total); - return res; + 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: () => { - const cached = metadataCache.getFiles(); - if (cached && cached.page === page) { - return { data: cached.files, meta: { page: cached.page, total: cached.total } }; + let records; + if (parentId) { + 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, }); @@ -36,7 +119,10 @@ export function useFile(id: string) { 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}`), + queryFn: () => + apiClient.get<{ data: { id: string; name: string; url: string; size: number } }>( + `${ENDPOINTS.FILE}/${fileId}`, + ), enabled: !!fileId, }); } @@ -45,10 +131,13 @@ export function useDeleteFile() { const queryClient = useQueryClient(); 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: () => { queryClient.invalidateQueries({ queryKey: ['files'] }); - metadataCache.clear(); }, }); } @@ -61,7 +150,6 @@ export function useAddTags() { apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['files'] }); - metadataCache.clear(); }, }); } @@ -74,7 +162,6 @@ export function useMoveFiles() { apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['files'] }); - metadataCache.clear(); }, }); } @@ -82,15 +169,112 @@ export function useMoveFiles() { export function useFolders() { return useQuery({ 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) { - return useQuery({ - queryKey: ['files', 'parent', parentId], - queryFn: () => - apiClient.get<{ data: FileItem[] }>(`${ENDPOINTS.FOLDERS}/${parentId}/files?thumbnail=thumbnail`), - enabled: !!parentId, + return useFiles(parentId); +} + +export function useDownloadFile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (file: UnifiedFileItem): Promise => { + 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'] }); + }, }); } diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts index 9a1ecb7..5ca832b 100644 --- a/mobile/hooks/useLocalFiles.ts +++ b/mobile/hooks/useLocalFiles.ts @@ -1,7 +1,7 @@ import { useMemo, useEffect, useRef } from 'react'; import { useDeviceFiles } from './useDeviceFiles'; -import { localFileRegistry } from '../services/localFileRegistry'; -import { LocalFileEntry } from '../types'; +import { fileStore } from '../services/fileStore'; +import { UnifiedFileItem } from '../types'; export function useLocalFiles() { 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; lastDeviceCount.current = deviceFiles.length; - const newEntries: LocalFileEntry[] = []; - for (const df of deviceFiles) { - if (localFileRegistry.get(df.id)) continue; - newEntries.push({ + fileStore.mergeFromDevice( + deviceFiles.map((df) => ({ id: df.id, - localUri: df.uri, + uri: df.uri, name: df.name, mimeType: df.mimeType, size: df.size, - syncStatus: 'local', createdAt: df.createdAt, folderId: df.folderId, - }); - } - if (newEntries.length > 0) { - localFileRegistry.registerBatch(newEntries); - } + })), + ); }, [deviceFiles]); const localFiles = useMemo(() => { - const registryEntries = localFileRegistry.getAll(); - const merged = new Map(); + const registryEntries = fileStore.getAllLocal(); + const merged = new Map(); 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) { - if (!merged.has(df.id)) { + if (!merged.has(df.id) && !fileStore.isDeleted(df.id)) { merged.set(df.id, { id: df.id, - localUri: df.uri, name: df.name, mimeType: df.mimeType, size: df.size, - syncStatus: 'local', createdAt: df.createdAt, - folderId: df.folderId, + source: 'local', + syncStatus: 'local', + localUri: df.uri, + tags: [], + isFolder: false, + isDeviceFile: true, + parentFileId: df.folderId, }); } } diff --git a/mobile/hooks/usePullSync.ts b/mobile/hooks/usePullSync.ts index dc339c8..8dfb716 100644 --- a/mobile/hooks/usePullSync.ts +++ b/mobile/hooks/usePullSync.ts @@ -1,10 +1,8 @@ import { useCallback, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy'; -import { useFiles } from './useFiles'; -import { localFileRegistry } from '../services/localFileRegistry'; +import { fileStore } from '../services/fileStore'; import { apiClient } from '../api/client'; -import { LocalFileEntry } from '../types'; import { setIsSyncing } from './useSyncQueue'; const SYNC_DIR = `${documentDirectory}synced-files/`; @@ -30,9 +28,9 @@ export function usePullSync() { }> }>('/files?page=1&limit=100&thumbnail=thumbnail'); const backendFiles = res.data ?? []; - const registry = localFileRegistry.getAll(); + const registry = fileStore.getAllSynced(); 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; @@ -51,17 +49,23 @@ export function usePullSync() { const result = await downloadAsync(downloadUrl, fileUri); - const entry: LocalFileEntry = { - id: `pull_${bf.id}`, - backendFileId: bf.id, - localUri: result.uri, + fileStore.upsert({ + id: bf.id, + backendId: bf.id, name: bf.name, mimeType: bf.mimeType, size: bf.size, + source: 'synced', + localUri: result.uri, syncStatus: 'synced', + parentFileId: null, + isFolder: 0, + ocrText: null, + thumbnailUrl: null, createdAt: bf.createdAt, - }; - localFileRegistry.register(entry); + updatedAt: bf.createdAt, + lastSyncedAt: new Date().toISOString(), + }); pulled++; } catch { // skip individual file failures diff --git a/mobile/hooks/useSyncQueue.ts b/mobile/hooks/useSyncQueue.ts index 1211486..b479975 100644 --- a/mobile/hooks/useSyncQueue.ts +++ b/mobile/hooks/useSyncQueue.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { createMMKV } from 'react-native-mmkv'; -import { localFileRegistry } from '../services/localFileRegistry'; +import { fileStore } from '../services/fileStore'; import { safDirectory } from '../services/safDirectory'; import { useDeviceFiles } from './useDeviceFiles'; @@ -28,19 +28,16 @@ export function useSyncQueue() { return; } - const registry = localFileRegistry.getAll(); + const registry = fileStore.getPendingSync(); let count = 0; for (const entry of registry) { - if (entry.backendFileId) continue; - if (entry.syncStatus !== 'local') continue; - if (globalMode === 'auto') { count++; } else { // mode manuel : uniquement les fichiers des dossiers en mode auto - if (!entry.folderId) continue; - const folder = safDirectory.getAll().find((f) => f.id === entry.folderId); + if (!entry.parentFileId) continue; + const folder = safDirectory.getAll().find((f) => f.id === entry.parentFileId); if (folder && folder.syncMode === 'auto') { count++; } diff --git a/mobile/hooks/useUnifiedFiles.ts b/mobile/hooks/useUnifiedFiles.ts deleted file mode 100644 index 09cd713..0000000 --- a/mobile/hooks/useUnifiedFiles.ts +++ /dev/null @@ -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(); - - const registryAll = localFileRegistry.getAll(); - const backendIdToLocal = new Map(); - for (const entry of registryAll) { - if (entry.backendFileId) { - backendIdToLocal.set(entry.backendFileId, entry); - } - } - - const nameSizeIndex = new Map(); - 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(); - - const registryAll = localFileRegistry.getAll(); - const backendIdToLocal = new Map(); - 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 => { - 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'] }); - }, - }); -} diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 927b7b1..4200bf3 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -12,7 +12,10 @@ "@react-native-community/netinfo": "12.0.1", "@react-navigation/native": "^7.3.8", "@react-navigation/native-stack": "^7.17.10", + "@tanstack/query-async-storage-persister": "^5.101.4", "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-persist-client": "^5.101.4", + "drizzle-orm": "^0.45.2", "expo": "~57.0.4", "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", @@ -21,6 +24,7 @@ "expo-media-library": "~57.0.3", "expo-print": "~57.0.0", "expo-secure-store": "~57.0.0", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-native": "0.86.0", @@ -36,6 +40,7 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "drizzle-kit": "^0.31.10", "typescript": "~6.0.3" } }, @@ -1177,6 +1182,13 @@ "node": ">=6.9.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -1189,6 +1201,884 @@ "node": ">=0.8.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2018,23 +2908,50 @@ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.101.4.tgz", + "integrity": "sha512-ROenNOVvxIZ1zKsBAiIvvslLo2xD8Me/vFz/tChdmuI7ciU+ET63yRrQ01W899Tx1ayQXdsGQJqYqjjVZk+OBQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4", + "@tanstack/query-persist-client-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", - "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/react-query": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", - "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.101.4.tgz", + "integrity": "sha512-Bmu+RfWhwYEyYZEwSeKC0gX4+KTkiEB3yO8oz9BfAY/Jfe3ndz9EnYVhihIZPmWIr8NXayF7JuraNQcrXQQCCg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2" + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" }, "funding": { "type": "github", @@ -2044,6 +2961,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.101.4.tgz", + "integrity": "sha512-CY5i7PPKS/5B8OR2zAs9nCbLpgqI4jN8eBhtAxKRMNHyGcQhmyOy4PkkJrAAXT+ecGj++fBJTgQw6OpyCt/2SQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^18 || ^19" + } + }, "node_modules/@types/hammerjs": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", @@ -2264,6 +3198,12 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -2898,6 +3838,147 @@ "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", "license": "MIT" }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2943,6 +4024,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3207,6 +4330,20 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sqlite": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-57.0.1.tgz", + "integrity": "sha512-I6KoUvfGIiROTKxr5D3H+jRIGA/iEvWEtqHK4XMukAA7tVTinz/YdS8zOz6/DdG6vgrNmvF7gyOcbhLinlfxzQ==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-status-bar": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.0.tgz", @@ -3735,6 +4872,21 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3762,6 +4914,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/getenv": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", @@ -5826,6 +6991,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve-workspace-root": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", @@ -6373,6 +7548,509 @@ "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", "license": "MIT" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/type-fest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", diff --git a/mobile/package.json b/mobile/package.json index c7bf025..de6c8b1 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,10 @@ "@react-native-community/netinfo": "12.0.1", "@react-navigation/native": "^7.3.8", "@react-navigation/native-stack": "^7.17.10", + "@tanstack/query-async-storage-persister": "^5.101.4", "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-persist-client": "^5.101.4", + "drizzle-orm": "^0.45.2", "expo": "~57.0.4", "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", @@ -16,6 +19,7 @@ "expo-media-library": "~57.0.3", "expo-print": "~57.0.0", "expo-secure-store": "~57.0.0", + "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-native": "0.86.0", @@ -31,6 +35,7 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "drizzle-kit": "^0.31.10", "typescript": "~6.0.3" }, "scripts": { diff --git a/mobile/services/fileStore/index.ts b/mobile/services/fileStore/index.ts new file mode 100644 index 0000000..9a690a4 --- /dev/null +++ b/mobile/services/fileStore/index.ts @@ -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 | 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`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) { + const d = getDb(); + const setFields: Record = {}; + 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`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))); + }, +}; diff --git a/mobile/services/fileStore/migrate.ts b/mobile/services/fileStore/migrate.ts new file mode 100644 index 0000000..7aec87b --- /dev/null +++ b/mobile/services/fileStore/migrate.ts @@ -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; +} + +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 {} +} diff --git a/mobile/services/fileStore/schema.ts b/mobile/services/fileStore/schema.ts new file mode 100644 index 0000000..967e33e --- /dev/null +++ b/mobile/services/fileStore/schema.ts @@ -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(), +}); diff --git a/mobile/services/localFileRegistry.ts b/mobile/services/localFileRegistry.ts deleted file mode 100644 index d6f0e1f..0000000 --- a/mobile/services/localFileRegistry.ts +++ /dev/null @@ -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; - backendIndex: Record; -} - -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) { - 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; - }, -}; diff --git a/mobile/services/metadataCache.ts b/mobile/services/metadataCache.ts deleted file mode 100644 index c49a7e6..0000000 --- a/mobile/services/metadataCache.ts +++ /dev/null @@ -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); - }, -}; diff --git a/mobile/services/mmkvPersister.ts b/mobile/services/mmkvPersister.ts new file mode 100644 index 0000000..f552603 --- /dev/null +++ b/mobile/services/mmkvPersister.ts @@ -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'); + }, + }; +} diff --git a/mobile/services/thumbnailCache.ts b/mobile/services/thumbnailCache.ts deleted file mode 100644 index 37347c4..0000000 --- a/mobile/services/thumbnailCache.ts +++ /dev/null @@ -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 | null = null; - -function loadCache(): Record { - if (memoryCache) return memoryCache; - const raw = storage.getString(BLOB_KEY); - memoryCache = raw ? JSON.parse(raw) : {}; - return memoryCache!; -} - -function saveCache(cache: Record) { - 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); - }, -}; diff --git a/mobile/types/index.ts b/mobile/types/index.ts index 2f596db..1cf531e 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -8,13 +8,17 @@ export interface Thumbnail { mimeType: string; } -export interface FileItem { +export interface UnifiedFileItem { id: string; + backendFileId?: string; name: string; mimeType: string; size: number; createdAt: string; - updatedAt: string; + updatedAt?: string; + source: 'cloud' | 'local' | 'synced'; + syncStatus: SyncStatus; + localUri?: string; ocrText?: string; tags: Tag[]; isFolder: boolean; @@ -22,9 +26,12 @@ export interface FileItem { url?: string; thumbnailUrl?: string; 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; } @@ -118,16 +125,3 @@ export interface RefreshResponse { } 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; -}