sqlite database

This commit is contained in:
m
2026-07-28 00:16:31 +02:00
parent e8ceb99a5d
commit cae6c4d72d
23 changed files with 2728 additions and 744 deletions
+9 -9
View File
@@ -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
-12
View File
@@ -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,
};
}
+207 -23
View File
@@ -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<typeof fileStore.getById>): UnifiedFileItem | null {
if (!record) return null;
return {
id: record.id,
backendFileId: record.backendId ?? undefined,
name: record.name,
mimeType: record.mimeType,
size: record.size,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
source: record.source as UnifiedFileItem['source'],
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
localUri: record.localUri ?? undefined,
ocrText: record.ocrText ?? undefined,
tags: record.tags ?? [],
isFolder: record.isFolder === 1,
parentFileId: record.parentFileId ?? undefined,
thumbnailUrl: record.thumbnailUrl ?? undefined,
isDeviceFile: record.source === 'local' && !record.backendId,
};
}
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getPaginated>): {
data: UnifiedFileItem[];
meta: { page: number; total: number };
} {
return {
data: records.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: records.total },
};
}
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 50) {
const queryKey = parentId
? ['files', parentId]
: ['files', 'root', page, limit];
export function useFiles(page: number = 1, limit: number = 20) {
return useQuery({
queryKey: ['files', page, limit],
queryKey,
queryFn: async () => {
const res = await apiClient.get<PaginatedResponse<FileItem>>(
`${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<PaginatedResponse<FileItem>>(
`${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<string> => {
if (file.syncStatus !== 'cloud') {
return file.localUri ?? '';
}
const res = await apiClient.get<{ data: { url: string; name: string } }>(
`/files/${file.backendFileId}`,
);
const downloadUrl = res.data.url;
const { downloadAsync, documentDirectory, makeDirectoryAsync } = await import('expo-file-system/legacy');
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
let hash = 0;
for (let i = 0; i < file.name.length; i++) {
hash = ((hash << 5) - hash + file.name.charCodeAt(i)) | 0;
}
const cacheKey = Math.abs(hash).toString(36);
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot) : '';
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
const result = await downloadAsync(downloadUrl, fileUri);
fileStore.upsert({
id: file.backendFileId ?? file.id,
backendId: file.backendFileId ?? file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced',
parentFileId: file.parentFileId ?? null,
isFolder: 0,
ocrText: file.ocrText ?? null,
thumbnailUrl: file.thumbnailUrl ?? null,
createdAt: file.createdAt,
updatedAt: file.updatedAt ?? file.createdAt,
lastSyncedAt: new Date().toISOString(),
tags: file.tags,
});
queryClient.invalidateQueries({ queryKey: ['files'] });
return result.uri;
},
});
}
export function useFreeLocalSpace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (fileIds: string[]) => {
const { deleteAsync } = await import('expo-file-system/legacy');
for (const fid of fileIds) {
const entry = fileStore.getByBackendId(fid);
if (!entry) continue;
if (entry.localUri) {
try {
await deleteAsync(entry.localUri, { idempotent: true });
} catch {}
}
fileStore.markAsCloudOnly(entry.id);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
});
}
+32 -20
View File
@@ -1,7 +1,7 @@
import { useMemo, useEffect, useRef } from 'react';
import { 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<string, LocalFileEntry>();
const registryEntries = fileStore.getAllLocal();
const merged = new Map<string, UnifiedFileItem>();
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,
});
}
}
+15 -11
View File
@@ -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
+4 -7
View File
@@ -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++;
}
-286
View File
@@ -1,286 +0,0 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } from 'expo-file-system/legacy';
import { useFiles } from './useFiles';
import { useLocalFiles } from './useLocalFiles';
import { localFileRegistry } from '../services/localFileRegistry';
import { thumbnailCache } from '../services/thumbnailCache';
import { apiClient } from '../api/client';
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
export interface UnifiedFileItem {
id: string;
backendFileId?: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
updatedAt?: string;
syncStatus: SyncStatus;
localUri?: string;
ocrText?: string;
tags: Tag[];
isFolder: boolean;
parentFileId?: string;
url?: string;
thumbnailUrl?: string;
isDeviceFile?: boolean;
duplicateOf?: string;
}
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
function getCacheKey(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return Math.abs(hash).toString(36);
}
function getExtension(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot) : '';
}
function parseExpiresFromUrl(url: string): number {
try {
const u = new URL(url);
const expires = u.searchParams.get('expires');
if (expires) return Number(expires) * 1000;
} catch {}
return Date.now() + 50 * 60 * 1000;
}
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
const unifiedFiles = useMemo(() => {
const backendFiles = backendData?.data ?? [];
const merged = new Map<string, UnifiedFileItem>();
const registryAll = localFileRegistry.getAll();
const backendIdToLocal = new Map<string, LocalFileEntry>();
for (const entry of registryAll) {
if (entry.backendFileId) {
backendIdToLocal.set(entry.backendFileId, entry);
}
}
const nameSizeIndex = new Map<string, string>();
for (const bf of backendFiles) {
if (!bf.isFolder && bf.size > 0) {
nameSizeIndex.set(`${bf.name}::${bf.size}`, bf.id);
}
}
for (const bf of backendFiles) {
const localEntry = backendIdToLocal.get(bf.id);
let thumbUrl = bf.thumbnailUrl;
if (thumbUrl && bf.thumbnailUrl) {
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
} else {
const cached = thumbnailCache.get(bf.id);
if (cached) thumbUrl = cached;
}
merged.set(bf.id, {
id: bf.id,
backendFileId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt,
syncStatus: localEntry ? 'synced' : 'cloud',
localUri: localEntry?.localUri,
ocrText: bf.ocrText,
tags: bf.tags,
isFolder: bf.isFolder,
parentFileId: bf.parentFileId,
url: bf.url,
thumbnailUrl: thumbUrl,
});
}
for (const lf of localFiles) {
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
if (merged.has(lf.id)) continue;
if (!lf.folderId && lf.size > 0) {
const key = `${lf.name}::${lf.size}`;
const matchId = nameSizeIndex.get(key);
if (matchId) {
const existing = merged.get(matchId);
if (existing && !existing.localUri && lf.localUri) {
merged.set(matchId, { ...existing, localUri: lf.localUri, syncStatus: 'synced' });
}
continue;
}
}
merged.set(lf.id, {
id: lf.id,
backendFileId: lf.backendFileId,
name: lf.name,
mimeType: lf.mimeType,
size: lf.size,
createdAt: lf.createdAt,
syncStatus: 'local',
localUri: lf.localUri,
tags: lf.tags ?? [],
isFolder: false,
isDeviceFile: true,
});
}
return Array.from(merged.values()).sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA;
});
}, [backendData, localFiles]);
return {
data: unifiedFiles,
isLoading: backendLoading || localLoading,
error: backendError,
hasPermission,
requestPermission,
pickDirectory,
folders,
refreshFolders,
};
}
export function useUnifiedFilesByParent(parentId: string) {
const { data: backendData, isLoading: backendLoading } = useQuery({
queryKey: ['files', 'parent', parentId],
queryFn: () =>
apiClient.get<{ data: FileItem[] }>(`/files/folders/${parentId}/files?thumbnail=thumbnail`),
enabled: !!parentId,
});
const { localFiles, isLoading: localLoading } = useLocalFiles();
const unifiedFiles = useMemo(() => {
const backendFiles = backendData?.data ?? [];
const merged = new Map<string, UnifiedFileItem>();
const registryAll = localFileRegistry.getAll();
const backendIdToLocal = new Map<string, LocalFileEntry>();
for (const entry of registryAll) {
if (entry.backendFileId) backendIdToLocal.set(entry.backendFileId, entry);
}
for (const bf of backendFiles) {
const localEntry = backendIdToLocal.get(bf.id);
let thumbUrl = bf.thumbnailUrl;
if (thumbUrl && bf.thumbnailUrl) {
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
} else {
const cached = thumbnailCache.get(bf.id);
if (cached) thumbUrl = cached;
}
merged.set(bf.id, {
id: bf.id,
backendFileId: bf.id,
name: bf.name,
mimeType: bf.mimeType,
size: bf.size,
createdAt: bf.createdAt,
updatedAt: bf.updatedAt,
syncStatus: localEntry ? 'synced' : 'cloud',
localUri: localEntry?.localUri,
ocrText: bf.ocrText,
tags: bf.tags,
isFolder: bf.isFolder,
parentFileId: bf.parentFileId,
url: bf.url,
thumbnailUrl: thumbUrl,
});
}
return Array.from(merged.values()).sort((a, b) => {
if (a.isFolder && !b.isFolder) return -1;
if (!a.isFolder && b.isFolder) return 1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}, [backendData, localFiles]);
return {
data: unifiedFiles,
isLoading: backendLoading || localLoading,
};
}
export function useDownloadFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: UnifiedFileItem): Promise<string> => {
if (file.syncStatus !== 'cloud') {
return file.localUri ?? '';
}
const res = await apiClient.get<{ data: { url: string; name: string } }>(
`/files/${file.backendFileId}`
);
const downloadUrl = res.data.url;
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
const cacheKey = getCacheKey(file.name);
const ext = getExtension(file.name);
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
const result = await downloadAsync(downloadUrl, fileUri);
const entry: LocalFileEntry = {
id: `local_${file.backendFileId}`,
backendFileId: file.backendFileId,
localUri: result.uri,
name: file.name,
mimeType: file.mimeType,
size: file.size,
syncStatus: 'synced',
createdAt: file.createdAt,
tags: file.tags,
};
localFileRegistry.register(entry);
queryClient.invalidateQueries({ queryKey: ['files'] });
return result.uri;
},
});
}
export function useFreeLocalSpace() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (fileIds: string[]) => {
for (const fid of fileIds) {
const entry = localFileRegistry.get(fid);
if (!entry) continue;
if (entry.localUri) {
try {
await deleteAsync(entry.localUri, { idempotent: true });
} catch {}
}
localFileRegistry.markAsCloudOnly(fid);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
});
}