sqlite database
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user