from scratch

This commit is contained in:
m
2026-09-09 18:23:01 +02:00
parent c7467ea8f9
commit 2ba837bcb0
87 changed files with 721 additions and 15218 deletions
-203
View File
@@ -1,203 +0,0 @@
import { useEffect, useCallback, useRef } from 'react';
import { createMMKV } from 'react-native-mmkv';
import { useQueryClient } from '@tanstack/react-query';
import { File, UploadType } from 'expo-file-system';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { safDirectory } from '../services/safDirectory';
import { fileStore } from '../services/fileStore';
import { activeUploadUris } from '../services/uploadQueue';
import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError } from '../types';
import {
setIsSyncing,
setSyncProgress,
isSyncLoopRunning,
setSyncLoopRunning,
requestSyncCancel,
isSyncCancelRequested,
consumeSyncCancel,
} from './useSyncQueue';
const MAX_RETRIES = 5;
const RETRY_MMKV_ID = 'vaultdrop-sync-retries';
const retryStorage = createMMKV({ id: RETRY_MMKV_ID });
function getRetryCount(fileId: string): number {
return retryStorage.getNumber(`${fileId}_retries`) ?? 0;
}
function incrementRetry(fileId: string): number {
const count = getRetryCount(fileId) + 1;
retryStorage.set(`${fileId}_retries`, count);
return count;
}
function resetRetry(fileId: string) {
retryStorage.remove(`${fileId}_retries`);
}
interface UploadResult {
name: string;
id: string;
}
async function uploadFile(file: { uri: string; type: string; name: string }): Promise<UploadResult> {
const fsFile = new File(file.uri);
const headers: Record<string, string> = {};
const token = apiClient.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
httpMethod: 'POST',
uploadType: UploadType.MULTIPART,
fieldName: 'file',
mimeType: file.type,
headers,
});
if (result.status >= 400) {
let message = 'Upload failed';
try {
const body: ApiError = JSON.parse(result.body);
message = body.error?.message || message;
} catch {
message = result.body || message;
}
throw new Error(message);
}
const body = JSON.parse(result.body);
const items = body.data ?? body;
const item = Array.isArray(items) ? items[0] : items;
return item as UploadResult;
}
function canSyncBasedOnNetwork(netInfo: NetInfoState, cellularAllowed: boolean): boolean {
if (!netInfo.isConnected) return false;
if (!cellularAllowed && netInfo.type !== 'wifi') return false;
return true;
}
let autoSyncLoopStarted = false;
export function useAutoSync() {
const queryClient = useQueryClient();
const isRunning = useRef(false);
const getPendingFiles = useCallback((): Array<ReturnType<typeof fileStore.getAllLocal>[number]> => {
return fileStore.getAllLocal().filter(
(entry) => !entry.backendId && entry.syncStatus === 'local' && entry.localUri
);
}, []);
const cancelSync = useCallback(() => {
requestSyncCancel();
}, []);
const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => {
if (isRunning.current) return;
if (isSyncLoopRunning()) return;
const globalCellular = safDirectory.getGlobalSyncCellular();
const netInfo = await NetInfo.fetch();
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
isRunning.current = true;
setSyncLoopRunning(true);
try {
setIsSyncing(true);
consumeSyncCancel();
const progressFiles = pendingFiles.map((f) => ({ id: f.id, name: f.name }));
for (let index = 0; index < pendingFiles.length; index++) {
if (isSyncCancelRequested()) break;
const entry = pendingFiles[index];
setSyncProgress({ files: progressFiles, currentIndex: index });
const uri = entry.localUri;
if (!uri || activeUploadUris.has(uri)) continue;
activeUploadUris.add(uri);
try {
const uploaded = await uploadFile({
uri,
type: entry.mimeType,
name: entry.name,
});
resetRetry(entry.id);
fileStore.updatePartial(entry.id, {
backendId: uploaded.id,
syncStatus: 'synced',
source: 'synced',
});
} catch (err) {
const retries = incrementRetry(entry.id);
if (retries >= MAX_RETRIES) {
fileStore.updatePartial(entry.id, {
syncStatus: 'error',
});
resetRetry(entry.id);
}
} finally {
activeUploadUris.delete(uri);
}
}
if (consumeSyncCancel()) {
return;
}
queryClient.invalidateQueries({ queryKey: ['resources'] });
} finally {
setSyncProgress(null);
setIsSyncing(false);
isRunning.current = false;
setSyncLoopRunning(false);
}
}, [queryClient]);
const checkAndSync = useCallback(async () => {
const globalMode = safDirectory.getGlobalSyncMode();
if (globalMode === 'off') return;
let pendingFiles = getPendingFiles();
if (globalMode === 'manual') {
const allFolders = safDirectory.getAll();
const autoFolderIds = new Set(
allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id)
);
pendingFiles = pendingFiles.filter(
(entry) => entry.parentResourceId && autoFolderIds.has(entry.parentResourceId)
);
}
if (pendingFiles.length === 0) return;
await runPendingSync(pendingFiles);
}, [getPendingFiles, runPendingSync]);
const syncManually = useCallback(async () => {
const pendingFiles = getPendingFiles();
if (pendingFiles.length === 0) return;
await runPendingSync(pendingFiles);
}, [getPendingFiles, runPendingSync]);
useEffect(() => {
if (autoSyncLoopStarted) return;
autoSyncLoopStarted = true;
const timeout = setTimeout(() => {
checkAndSync();
}, 5_000);
const interval = setInterval(checkAndSync, 30_000);
return () => { clearTimeout(timeout); clearInterval(interval); autoSyncLoopStarted = false; };
}, [checkAndSync]);
return { triggerSync: checkAndSync, syncManually, cancelSync };
}
-71
View File
@@ -1,71 +0,0 @@
import { useCallback } from 'react';
import { createMMKV, useMMKVObject } from 'react-native-mmkv';
import { Batch } from '../types';
const storage = createMMKV({ id: 'vaultdrop-batches' });
export function useBatchStore() {
const [batchIds, setBatchIds] = useMMKVObject<string[]>('batch-ids', storage);
const saveBatch = useCallback((batch: Batch) => {
storage.set(`batch_${batch.id}`, JSON.stringify(batch));
const current = batchIds ?? [];
if (!current.includes(batch.id)) {
setBatchIds([batch.id, ...current]);
}
}, [batchIds, setBatchIds]);
const getBatch = useCallback((id: string): Batch | undefined => {
const raw = storage.getString(`batch_${id}`);
if (!raw) return undefined;
return JSON.parse(raw) as Batch;
}, []);
const getAllBatches = useCallback((): Batch[] => {
return (batchIds ?? [])
.map((id) => getBatch(id))
.filter((b): b is Batch => b !== undefined);
}, [batchIds, getBatch]);
const addTagToBatch = useCallback((batchId: string, tag: string) => {
const batch = getBatch(batchId);
if (!batch) return;
if (batch.tags.includes(tag)) return;
batch.tags = [...batch.tags, tag];
storage.set(`batch_${batchId}`, JSON.stringify(batch));
}, [getBatch]);
const removeTagFromBatch = useCallback((batchId: string, tag: string) => {
const batch = getBatch(batchId);
if (!batch) return;
batch.tags = batch.tags.filter((t) => t !== tag);
storage.set(`batch_${batchId}`, JSON.stringify(batch));
}, [getBatch]);
const deleteBatch = useCallback((batchId: string) => {
storage.set(`batch_${batchId}`, undefined as any);
storage.remove(`batch_${batchId}` as any);
setBatchIds((batchIds ?? []).filter((id) => id !== batchId));
}, [batchIds, setBatchIds]);
const removePhotoFromBatch = useCallback((batchId: string, photoId: string) => {
const batch = getBatch(batchId);
if (!batch) return;
batch.photos = batch.photos.filter((p) => p.id !== photoId);
if (batch.photos.length === 0) {
deleteBatch(batchId);
} else {
storage.set(`batch_${batchId}`, JSON.stringify(batch));
}
}, [getBatch, deleteBatch]);
return {
saveBatch,
getBatch,
getAllBatches,
addTagToBatch,
removeTagFromBatch,
deleteBatch,
removePhotoFromBatch,
};
}
-133
View File
@@ -1,133 +0,0 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera';
import { useUpload } from './useUpload';
import { CapturedPhoto } from '../types';
type CaptureStatus = 'idle' | 'capturing' | 'uploading' | 'success' | 'error';
export function useCameraCapture() {
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const cameraRef = useRef<CameraRef>(null);
const [captureStatus, setCaptureStatus] = useState<CaptureStatus>('idle');
const [captureError, setCaptureError] = useState<string>();
const [torchMode, setTorchMode] = useState<TorchMode>('off');
const [cameraReady, setCameraReady] = useState(false);
const [capturedPhotos, setCapturedPhotos] = useState<CapturedPhoto[]>([]);
const upload = useUpload();
const photoOutput = usePhotoOutput();
useEffect(() => {
if (!hasPermission) {
requestPermission();
}
}, [hasPermission, requestPermission]);
const isActive = hasPermission && !!device && captureStatus !== 'uploading';
const toggleTorch = useCallback(async () => {
const next = torchMode === 'off' ? 'on' : 'off';
setTorchMode(next);
if (cameraReady && cameraRef.current) {
try {
const controller = cameraRef.current.controller;
if (controller?.device.hasTorch) {
await controller.setTorchMode(next);
}
} catch {
setTorchMode(torchMode);
}
}
}, [torchMode, cameraReady]);
const onStarted = useCallback(() => {
setCameraReady(true);
if (torchMode === 'on' && cameraRef.current) {
const controller = cameraRef.current.controller;
controller?.setTorchMode('on').catch(() => {});
}
}, [torchMode]);
const onStopped = useCallback(() => {
setCameraReady(false);
}, []);
const addCapturedPhoto = useCallback((photo: CapturedPhoto) => {
setCapturedPhotos((prev) => [...prev, photo]);
}, []);
const removeCapturedPhoto = useCallback((id: string) => {
setCapturedPhotos((prev) => prev.filter((p) => p.id !== id));
}, []);
const clearCapturedPhotos = useCallback(() => {
setCapturedPhotos([]);
}, []);
const capturePhoto = useCallback(async () => {
if (!photoOutput) return;
const photoId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
try {
setCaptureStatus('capturing');
setCaptureError(undefined);
const { filePath } = await photoOutput.capturePhotoToFile({}, {});
setCaptureStatus('uploading');
const capturedPhoto: CapturedPhoto = {
id: photoId,
filePath,
uri: 'file://' + filePath,
};
addCapturedPhoto(capturedPhoto);
const result = await upload.mutateAsync([
{ uri: capturedPhoto.uri, type: 'image/jpeg', name: `scan_${photoId}.jpg` },
]);
if (result.errors.length > 0) {
setCaptureStatus('error');
setCaptureError(result.errors[0].message);
} else {
capturedPhoto.uploadedId = result.uploaded[0]?.id;
capturedPhoto.uploadedAt = new Date().toISOString();
setCaptureStatus('success');
}
} catch (err) {
setCaptureStatus('error');
setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture');
}
setTimeout(() => {
setCaptureStatus('idle');
setCaptureError(undefined);
}, 1500);
}, [photoOutput, upload, addCapturedPhoto]);
return {
cameraRef,
hasPermission,
requestPermission,
device,
photoOutput,
capturePhoto,
captureStatus,
captureError,
isActive,
torchMode,
toggleTorch,
onStarted,
onStopped,
capturedPhotos,
removeCapturedPhoto,
clearCapturedPhotos,
capturedCount: capturedPhotos.length,
};
}
-12
View File
@@ -1,12 +0,0 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
-244
View File
@@ -1,244 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import * as FileSystem from 'expo-file-system/legacy';
import { safDirectory, type StoredFolder } from '../services/safDirectory';
import { downloadRegistry } from '../services/downloadRegistry';
import { useFileWatcher } from './useFileWatcher';
import { FileDetectedEvent } from '../modules/expo-download-detect';
export interface DeviceFile {
id: string;
uri: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
folderId?: string;
}
function guessMimeType(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
txt: 'text/plain',
csv: 'text/csv',
json: 'application/json',
xml: 'application/xml',
zip: 'application/zip',
rar: 'application/x-rar-compressed',
mp4: 'video/mp4',
mp3: 'audio/mpeg',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
};
return map[ext] ?? 'application/octet-stream';
}
function eventToDeviceFile(event: FileDetectedEvent): DeviceFile {
return {
id: event.id,
uri: event.uri,
name: event.name,
mimeType: event.mimeType,
size: event.size,
createdAt: new Date(event.createdAt).toISOString(),
};
}
const SKIP_SUBDIRS = new Set([
'Android', 'android', 'data', 'obb', 'cache',
'.thumbnails', '.Trash', 'lost+found',
'LOST.DIR', 'System Volume Information',
'com.android', '.cache', 'tmp', '.tmp',
]);
function shouldSkipDir(name: string): boolean {
if (name.startsWith('.')) return true;
return SKIP_SUBDIRS.has(name);
}
async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
try {
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri);
const files: DeviceFile[] = [];
for (const entryUri of entries) {
const parts = entryUri.split('/');
const name = decodeURIComponent(parts[parts.length - 1]);
if (name.startsWith('.')) continue;
files.push({
id: `saf_${folder.id}_${entryUri}`,
uri: entryUri,
name,
mimeType: guessMimeType(name),
size: 0,
createdAt: new Date().toISOString(),
folderId: folder.id,
});
}
return files;
} catch (err) {
return [];
}
}
export async function scanSubdirectories(
baseUri: string,
depth: number = 0,
maxDepth: number = 3,
discovered: Array<{ uri: string; name: string; parentUri: string }> = []
): Promise<Array<{ uri: string; name: string; parentUri: string }>> {
if (depth >= maxDepth || discovered.length >= 300) return discovered;
try {
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(baseUri);
for (const entryUri of entries) {
const parts = entryUri.split('/');
const name = decodeURIComponent(parts[parts.length - 1]);
if (shouldSkipDir(name)) continue;
try {
await FileSystem.StorageAccessFramework.readDirectoryAsync(entryUri);
discovered.push({ uri: entryUri, name, parentUri: baseUri });
await scanSubdirectories(entryUri, depth + 1, maxDepth, discovered);
} catch {}
}
} catch {}
return discovered;
}
export function useDeviceFiles() {
const [files, setFiles] = useState<DeviceFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered());
const { newFiles, clearNewFiles } = useFileWatcher();
const scanVisibleFolders = useCallback(async () => {
const visibleFolders = safDirectory.getVisibleFolders().filter((f) => f.source !== 'media-library');
if (visibleFolders.length === 0) return;
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
const safFiles = results.flat();
setFiles((prev) => {
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
const mediaOnly = prev.filter((f) => !f.folderId);
const merged = [...mediaOnly];
for (const f of safFiles) {
if (!existing.has(f.id)) merged.push(f);
}
return merged;
});
}, []);
const loadRegistryFiles = useCallback(() => {
const registryEntries = downloadRegistry.getAll();
const registryFiles: DeviceFile[] = registryEntries.map((entry) => ({
id: entry.id,
uri: entry.uri,
name: entry.name,
mimeType: entry.mimeType,
size: entry.size,
createdAt: new Date(entry.createdAt).toISOString(),
}));
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
const merged = [...prev];
for (const f of registryFiles) {
if (!existing.has(f.id)) merged.push(f);
}
return merged;
});
}, []);
const pickAndScanRecursive = useCallback(async (): Promise<{ addedFolders: number }> => {
try {
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!result.granted) return { addedFolders: 0 };
const dirUri = result.directoryUri;
const parts = dirUri.split('/');
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
safDirectory.addFolder(dirUri, dirName);
const subdirs = await scanSubdirectories(dirUri);
if (subdirs.length > 0) {
safDirectory.addBatchFolders(
subdirs.map((d) => ({ uri: d.uri, name: d.name, source: 'recursive', parentUri: d.parentUri }))
);
}
setFolders(safDirectory.getAll());
if (!discovered) {
safDirectory.setDiscovered();
setDiscovered(true);
}
await scanVisibleFolders();
return { addedFolders: 1 + subdirs.length };
} catch (err) {
return { addedFolders: 0 };
}
}, [discovered, scanVisibleFolders]);
const refreshFolders = useCallback(() => {
setFolders(safDirectory.getAll());
}, []);
const rescan = useCallback(async () => {
setIsLoading(true);
try {
await scanVisibleFolders();
loadRegistryFiles();
} finally {
setIsLoading(false);
}
}, [scanVisibleFolders, loadRegistryFiles]);
// Handle new files detected by native module
useEffect(() => {
if (newFiles.length === 0) return;
downloadRegistry.addBatch(newFiles);
const newDeviceFiles = newFiles.map(eventToDeviceFile);
setFiles((prev) => {
const existing = new Set(prev.map((f) => f.id));
const merged = [...prev];
for (const f of newDeviceFiles) {
if (!existing.has(f.id)) merged.push(f);
}
return merged;
});
clearNewFiles();
}, [newFiles, clearNewFiles]);
// Initial load
useEffect(() => {
scanVisibleFolders();
loadRegistryFiles();
}, [scanVisibleFolders, loadRegistryFiles]);
return {
files, isLoading,
rescan,
pickAndScanRecursive,
folders, refreshFolders,
discovered,
};
}
-103
View File
@@ -1,103 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { Device as DeviceType } from '../types';
const DEVICE_ID_KEY = 'vaultdrop_device_id';
const DEVICE_NAME_KEY = 'vaultdrop_device_name';
const DEVICE_SERVER_ID_KEY = 'vaultdrop_device_server_id';
function generateDefaultDeviceName(): string {
const constants = Platform.constants as Record<string, unknown>;
const brand = String(constants?.Manufacturer ?? '');
const model = String(constants?.Model ?? '');
const suffix = Math.random().toString(36).slice(2, 6);
const base = [brand, model].filter(Boolean).join(' ') || Platform.OS;
return `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${base} (${suffix})`;
}
async function getOrCreateDeviceId(): Promise<string> {
let deviceId = await SecureStore.getItemAsync(DEVICE_ID_KEY);
if (!deviceId) {
deviceId = `device_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId);
}
return deviceId;
}
export async function getStoredDeviceServerId(): Promise<string | null> {
return SecureStore.getItemAsync(DEVICE_SERVER_ID_KEY);
}
export async function getStoredDeviceName(): Promise<string | null> {
return SecureStore.getItemAsync(DEVICE_NAME_KEY);
}
export function useDeviceRegistration() {
const [device, setDevice] = useState<{ localId: string; serverId: string | null; name: string } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRegistered, setIsRegistered] = useState<boolean | null>(null);
const register = useCallback(async (deviceName: string) => {
const localId = await getOrCreateDeviceId();
const result = await apiClient.post<{ id: string; device_name: string; role: string }>(ENDPOINTS.DEVICES, {
device_name: deviceName,
});
await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, result.id);
await SecureStore.setItemAsync(DEVICE_NAME_KEY, result.device_name);
setDevice({ localId, serverId: result.id, name: result.device_name });
setIsRegistered(true);
return result;
}, []);
const checkRegistration = useCallback(async () => {
try {
const localId = await getOrCreateDeviceId();
const storedName = await getStoredDeviceName();
try {
const devices = await apiClient.get<DeviceType[]>(ENDPOINTS.DEVICES);
if (devices.length > 0) {
const existing = devices[0];
await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, existing.id);
const name = storedName || existing.device_name;
setDevice({ localId, serverId: existing.id, name });
setIsRegistered(true);
setIsLoading(false);
return;
}
} catch {
// Not registered yet
}
// Auto-register with a generated name instead of blocking
const autoName = storedName || generateDefaultDeviceName();
try {
await register(autoName);
} catch {
setDevice({ localId, serverId: null, name: autoName });
setIsRegistered(false);
}
} catch {
setIsRegistered(false);
} finally {
setIsLoading(false);
}
}, [register]);
useEffect(() => {
checkRegistration();
}, [checkRegistration]);
return {
device,
isLoading,
isRegistered,
register,
checkRegistration,
};
}
-32
View File
@@ -1,32 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { Platform } from 'react-native';
import { ExpoDownloadDetectModule, FileDetectedEvent } from '../modules/expo-download-detect';
export function useFileWatcher() {
const [newFiles, setNewFiles] = useState<FileDetectedEvent[]>([]);
const [isSupported] = useState(() => Platform.OS === 'android');
const clearNewFiles = useCallback(() => {
setNewFiles([]);
}, []);
useEffect(() => {
if (!isSupported) return;
ExpoDownloadDetectModule.startWatching();
const subscription = ExpoDownloadDetectModule.addListener('onNewFile', (event: FileDetectedEvent) => {
setNewFiles((prev) => {
if (prev.some((f) => f.id === event.id)) return prev;
return [...prev, event];
});
});
return () => {
subscription.remove();
ExpoDownloadDetectModule.stopWatching();
};
}, [isSupported]);
return { newFiles, clearNewFiles, isSupported };
}
-384
View File
@@ -1,384 +0,0 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types';
import { fileStore } from '../services/fileStore';
import { actionQueue } from '../services/actionQueue';
import { useNetworkStatus } from './useNetworkStatus';
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
if (!record) return null;
return {
id: record.id,
backendResourceId: record.backendId ?? undefined,
name: record.name,
mimeType: record.mimeType,
size: record.size,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
source: record.source as UnifiedFileItem['source'],
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
localUri: record.localUri ?? undefined,
ocrText: record.ocrText ?? undefined,
tags: record.tags ?? [],
isFolder: record.isFolder === 1,
parentResourceId: record.parentResourceId ?? undefined,
ownerId: record.ownerId ?? undefined,
thumbnailUrl: record.thumbnailUrl ?? undefined,
thumbnailLocal: record.thumbnailLocal ?? undefined,
isDeviceFile: record.source === 'local' && !record.backendId,
};
}
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
const queryKey = parentId
? ['resources', parentId, page, limit]
: ['resources', 'root', page, limit];
const { isOnline } = useNetworkStatus();
return useQuery({
queryKey,
queryFn: async () => {
if ( !isOnline ) {
if ( parentId ) {
const children = fileStore.getChildrenByParent(parentId);
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: children.length },
};
}
const cached = fileStore.getRootFiles();
const localDeviceFiles = fileStore.getLocalDeviceFiles();
const validFiles = cached.files.filter(
(f) => !f.backendId || returnedIds.has(f.backendId) || f.source === 'local',
);
const validIds = new Set(validFiles.map((f) => f.id));
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
const mergedFiles = [...validFiles, ...extraLocal];
return {
data: mergedFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: ( cached.total) + extraLocal.length },
};
}
if (parentId) {
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
`${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
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,
parentResourceId: f.parentResourceId,
ownerId: f.ownerId,
thumbnailUrl: f.thumbnailUrl,
})),
);
const children = fileStore.getChildrenByParent(parentId);
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: backendRes.meta?.total ?? children.length },
};
}
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
const returnedIds = new Set(backendRes.data.map((f) => f.id));
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,
parentResourceId: f.parentResourceId,
ownerId: f.ownerId,
thumbnailUrl: f.thumbnailUrl,
})),
);
const cached = fileStore.getRootFiles();
const localDeviceFiles = fileStore.getLocalDeviceFiles();
const validFiles = cached.files.filter(
(f) => !f.backendId || returnedIds.has(f.backendId) || f.source === 'local',
);
const validIds = new Set(validFiles.map((f) => f.id));
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
const mergedFiles = [...validFiles, ...extraLocal];
return {
data: mergedFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page, total: (backendRes.meta?.total ?? cached.total) + extraLocal.length },
};
},
placeholderData: keepPreviousData,
initialData: () => {
if (!parentId) {
const cached = fileStore.getRootFiles();
const localDeviceFiles = fileStore.getLocalDeviceFiles();
const validIds = new Set(cached.files.map((f) => f.id));
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
const allFiles = [...cached.files, ...extraLocal];
if (allFiles.length === 0) return undefined;
return {
data: allFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: cached.total + extraLocal.length },
};
}
const children = fileStore.getChildrenByParent(parentId);
if (children.length === 0) return undefined;
return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
meta: { page: 0, total: children.length },
};
},
staleTime: 30_000,
});
}
export function useFile(id: string) {
return useQuery({
queryKey: ['resources', id],
queryFn: async () => {
const res = await apiClient.get<{ data: FileItem }>(`${ENDPOINTS.RESOURCES}/${id}?thumbnail=thumbnail_small`);
return res.data;
},
enabled: !!id,
});
}
export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const record = fileStore.getByBackendId(id) ?? fileStore.getById(id);
if (record?.backendId) {
actionQueue.enqueue('delete', { backendId: record.backendId }, record.backendId);
}
fileStore.deleteByBackendId(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useAddTags() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ fileId, tags }: { fileId: string; tags: string[] }) => {
const record = fileStore.getById(fileId) ?? fileStore.getByBackendId(fileId);
if (record) {
const existingTags = record.tags ?? [];
const newTags = [...existingTags, ...tags.map((t) => ({ id: t, tag_name: t }))];
const uniqueTags = newTags.filter((t, i, arr) => arr.findIndex((x) => x.tag_name === t.tag_name) === i);
fileStore.updatePartial(record.id, {});
actionQueue.enqueue('tag_add', { fileId: record.backendId ?? record.id, tags }, record.backendId ?? record.id);
}
return Promise.resolve();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useMoveResources() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceIds, parentResourceId }: { resourceIds: string[]; parentResourceId: string | null }) => {
const backendIds: string[] = [];
for (const id of resourceIds) {
const record = fileStore.getById(id) ?? fileStore.getByBackendId(id);
if (record?.backendId) {
backendIds.push(record.backendId);
fileStore.updatePartial(record.id, { parentResourceId: parentResourceId ?? null });
}
}
if (backendIds.length > 0) {
actionQueue.enqueue('move', { resourceIds: backendIds, parentResourceId });
}
return Promise.resolve();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
export function useFolders() {
return useQuery({
queryKey: ['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,
parentResourceId: f.parentResourceId,
ownerId: f.ownerId,
thumbnailUrl: f.thumbnailUrl,
})),
);
return fileStore.getAllFolders();
},
initialData: () => {
const folders = fileStore.getAllFolders();
return folders.length > 0 ? folders : undefined;
},
staleTime: 60_000,
});
}
export function useCreateFolder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ name, parentResourceId }: { name: string; parentResourceId?: string }) => {
const now = new Date().toISOString();
const localId = `local_folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
fileStore.upsert({
id: localId,
backendId: null,
name,
mimeType: 'inode/directory',
size: 0,
source: 'local',
localUri: null,
syncStatus: 'local',
parentResourceId: parentResourceId ?? null,
isFolder: 1,
ocrText: null,
thumbnailUrl: null,
ownerId: null,
createdAt: now,
updatedAt: now,
lastSyncedAt: null,
});
actionQueue.enqueue('create_folder', { name, parentResourceId }, localId);
return { id: localId, name };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
queryClient.invalidateQueries({ queryKey: ['folders'] });
},
});
}
export function useFilesByParent(parentId: string) {
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<{ url: string }>(
`${ENDPOINTS.RESOURCES}/${file.backendResourceId ?? file.id}`,
);
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(res.url, fileUri);
fileStore.upsert({
id: file.backendResourceId ?? file.id,
backendId: file.backendResourceId ?? file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced',
parentResourceId: file.parentResourceId ?? null,
isFolder: 0,
ocrText: file.ocrText ?? null,
thumbnailUrl: file.thumbnailUrl ?? null,
ownerId: file.ownerId ?? null,
createdAt: file.createdAt,
updatedAt: file.updatedAt ?? file.createdAt,
lastSyncedAt: new Date().toISOString(),
tags: file.tags,
});
queryClient.invalidateQueries({ queryKey: ['resources'] });
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: ['resources'] });
},
});
}
-110
View File
@@ -1,110 +0,0 @@
import { useMemo, useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useDeviceFiles } from './useDeviceFiles';
import { fileStore } from '../services/fileStore';
import { generateLocalThumbnail } from '../services/thumbnail';
import { UnifiedFileItem } from '../types';
export function useLocalFiles() {
const { files: deviceFiles, isLoading: deviceLoading, rescan, pickAndScanRecursive, folders, refreshFolders, discovered } = useDeviceFiles();
const queryClient = useQueryClient();
const lastDeviceCount = useRef(0);
const thumbnailQueue = useRef<Set<string>>(new Set());
useEffect(() => {
if (deviceFiles.length === 0) return;
if (deviceFiles.length === lastDeviceCount.current) return;
lastDeviceCount.current = deviceFiles.length;
const toMerge = deviceFiles.map((df) => ({
id: df.id,
uri: df.uri,
name: df.name,
mimeType: df.mimeType,
size: df.size,
createdAt: df.createdAt,
folderId: df.folderId,
}));
fileStore.mergeFromDevice(toMerge);
const jobs: Promise<void>[] = [];
for (const df of toMerge) {
if (thumbnailQueue.current.has(df.id)) continue;
const mime = (df.mimeType ?? '').toLowerCase();
if (!mime.startsWith('image/')) continue;
if (fileStore.getById(df.id)?.thumbnailLocal) continue;
thumbnailQueue.current.add(df.id);
jobs.push(
generateLocalThumbnail(df.uri, df.mimeType).then((thumb) => {
try {
if (thumb) fileStore.setThumbnailLocal(df.id, thumb);
} catch {}
}).finally(() => {
thumbnailQueue.current.delete(df.id);
}),
);
}
if (jobs.length > 0) {
Promise.all(jobs).finally(() => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
});
}
}, [deviceFiles, queryClient]);
const localFiles = useMemo(() => {
const registryEntries = fileStore.getAllLocal();
const merged = new Map<string, UnifiedFileItem>();
for (const entry of registryEntries) {
merged.set(entry.id, {
id: entry.id,
backendResourceId: 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,
thumbnailUrl: entry.thumbnailUrl ?? undefined,
thumbnailLocal: entry.thumbnailLocal ?? undefined,
tags: entry.tags ?? [],
isFolder: entry.isFolder === 1,
parentResourceId: entry.parentResourceId ?? undefined,
isDeviceFile: entry.source === 'local' && !entry.backendId,
});
}
for (const df of deviceFiles) {
if (!merged.has(df.id) && !fileStore.isDeleted(df.id)) {
merged.set(df.id, {
id: df.id,
name: df.name,
mimeType: df.mimeType,
size: df.size,
createdAt: df.createdAt,
source: 'local',
syncStatus: 'local',
localUri: df.uri,
tags: [],
isFolder: false,
isDeviceFile: true,
parentResourceId: df.folderId,
});
}
}
return Array.from(merged.values());
}, [deviceFiles]);
return {
localFiles,
isLoading: deviceLoading,
rescan,
pickAndScanRecursive,
folders,
refreshFolders,
discovered,
};
}
-55
View File
@@ -1,55 +0,0 @@
import { useSyncExternalStore, useRef } from 'react';
import NetInfo, { NetInfoState, NetInfoSubscription } from '@react-native-community/netinfo';
type Listener = () => void;
let state: NetInfoState | null = null;
const listeners = new Set<Listener>();
let subscription: NetInfoSubscription | null = null;
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot(): boolean {
return state?.isConnected ?? true;
}
function initIfNeeded() {
if (subscription) return;
NetInfo.fetch().then((info) => {
state = info;
listeners.forEach((l) => l());
});
subscription = NetInfo.addEventListener((info) => {
state = info;
listeners.forEach((l) => l());
});
}
export interface NetworkStatus {
isOnline: boolean;
isWifi: boolean;
isCellular: boolean;
connectionType: string;
isInternetReachable: boolean | null;
}
export function useNetworkStatus(): NetworkStatus {
const mountRef = useRef(false);
if (!mountRef.current) {
initIfNeeded();
mountRef.current = true;
}
const isConnected = useSyncExternalStore(subscribe, getSnapshot);
return {
isOnline: isConnected,
isWifi: state?.type === 'wifi',
isCellular: state?.type === 'cellular',
connectionType: state?.type ?? 'unknown',
isInternetReachable: state?.isInternetReachable ?? null,
};
}
-55
View File
@@ -1,55 +0,0 @@
import { useState, useCallback } from 'react';
import * as Print from 'expo-print';
export function usePdfGeneration() {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const generatePdf = useCallback(async (items: { uri: string }[], progressCb?: (pct: number) => void): Promise<string | null> => {
if (items.length === 0) return null;
setGenerating(true);
setProgress(0);
progressCb?.(0);
try {
const imagesHtml = items.map((item) => {
return `<div style="page-break-after: always; display: flex; justify-content: center; align-items: center; height: 100vh;">
<img src="${item.uri}" style="max-width: 100%; max-height: 100vh; object-fit: contain;" />
</div>`;
}).join('');
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #fff; }
@media print {
@page { margin: 0; }
}
</style>
</head>
<body>${imagesHtml}</body>
</html>`;
const { uri } = await Print.printToFileAsync({ html });
setProgress(100);
progressCb?.(100);
return uri;
} catch (err) {
console.error('PDF generation failed:', err);
return null;
} finally {
setGenerating(false);
}
}, []);
return {
generatePdf,
generating,
progress,
};
}
-37
View File
@@ -1,37 +0,0 @@
import { useState, useEffect, useCallback, useSyncExternalStore, useRef } from 'react';
import { actionQueue } from '../services/actionQueue';
import { useNetworkStatus } from './useNetworkStatus';
function subscribe(listener: () => void): () => void {
return actionQueue.subscribe(listener);
}
function getSnapshot(): number {
return actionQueue.getPendingCount();
}
export function usePendingActions() {
const pendingCount = useSyncExternalStore(subscribe, getSnapshot);
const { isOnline } = useNetworkStatus();
const wasOffline = useRef(!isOnline);
useEffect(() => {
if (isOnline && wasOffline.current && pendingCount > 0) {
actionQueue.processAll();
}
wasOffline.current = !isOnline;
}, [isOnline, pendingCount]);
useEffect(() => {
if (pendingCount > 0 && isOnline) {
actionQueue.scheduleRetry(5_000);
}
return () => actionQueue.cancelSchedule();
}, [pendingCount, isOnline]);
return {
pendingCount,
isProcessing: actionQueue.isProcessing(),
processAll: useCallback(() => actionQueue.processAll(), []),
};
}
-28
View File
@@ -1,28 +0,0 @@
import React, { useEffect } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { fileStore } from '../services/fileStore';
import { useOcrDone } from '../contexts/SseContext';
function handleOcrDone(resourceId: string) {
apiClient
.get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
.then((detail) => {
const ocrText = detail.data?.ocrText;
if (ocrText) {
fileStore.updatePartial(resourceId, { ocrText });
}
})
.catch(() => {});
}
export function SseOcrListener() {
const { onOcrDone } = useOcrDone();
useEffect(() => {
const unsub = onOcrDone(handleOcrDone);
return unsub;
}, [onOcrDone]);
return null;
}
-102
View File
@@ -1,102 +0,0 @@
import { useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
import { fileStore } from '../services/fileStore';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { setIsSyncing } from './useSyncQueue';
const SYNC_DIR = `${documentDirectory}synced-files/`;
export function usePullSync() {
const queryClient = useQueryClient();
const isRunning = useRef(false);
const pullNewFiles = useCallback(async () => {
if (isRunning.current) return { pulled: 0 };
isRunning.current = true;
try {
setIsSyncing(true);
let page = 1;
const limit = 100;
let total = 0;
const backendResources: Array<{
id: string;
name: string;
mimeType: string;
size: number;
createdAt: string;
url?: string;
thumbnailUrl?: string;
ownerId?: string;
}> = [];
do {
const res = await apiClient.get<{ data: Array<typeof backendResources[number]>; meta?: { total: number } }>(
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
);
backendResources.push(...(res.data ?? []));
total = res.meta?.total ?? res.data.length;
page++;
} while (backendResources.length < total);
const registry = fileStore.getAllSynced();
const existingBackendIds = new Set(
registry.filter((e) => e.backendId).map((e) => e.backendId)
);
let pulled = 0;
for (const br of backendResources) {
if (existingBackendIds.has(br.id)) continue;
if (br.size === 0) continue;
try {
const detail = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${br.id}`);
const downloadUrl = detail.url;
await makeDirectoryAsync(SYNC_DIR, { intermediates: true });
const safeName = br.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const fileUri = `${SYNC_DIR}${br.id}_${safeName}`;
const result = await downloadAsync(downloadUrl, fileUri);
fileStore.upsert({
id: br.id,
backendId: br.id,
name: br.name,
mimeType: br.mimeType,
size: br.size,
source: 'synced',
localUri: result.uri,
syncStatus: 'synced',
parentResourceId: null,
isFolder: 0,
ocrText: null,
thumbnailUrl: br.thumbnailUrl ?? null,
ownerId: br.ownerId ?? null,
createdAt: br.createdAt,
updatedAt: br.createdAt,
lastSyncedAt: new Date().toISOString(),
});
pulled++;
} catch {
// skip individual file failures
}
}
if (pulled > 0) {
queryClient.invalidateQueries({ queryKey: ['resources'] });
}
return { pulled };
} finally {
setIsSyncing(false);
isRunning.current = false;
}
}, [queryClient]);
return { pullNewFiles };
}
-17
View File
@@ -1,17 +0,0 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useResourceCreated } from '../contexts/SseContext';
export function SseResourceListener() {
const queryClient = useQueryClient();
const { onResourceCreated } = useResourceCreated();
useEffect(() => {
const unsub = onResourceCreated(() => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
});
return unsub;
}, [onResourceCreated, queryClient]);
return null;
}
-44
View File
@@ -1,44 +0,0 @@
import { useMemo } from 'react';
import { fileStore } from '../services/fileStore';
import { UnifiedFileItem } from '../types';
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
if (!record) return null;
return {
id: record.id,
backendResourceId: record.backendId ?? undefined,
name: record.name,
mimeType: record.mimeType,
size: record.size,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
source: record.source as UnifiedFileItem['source'],
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
localUri: record.localUri ?? undefined,
ocrText: record.ocrText ?? undefined,
tags: record.tags ?? [],
isFolder: record.isFolder === 1,
parentResourceId: record.parentResourceId ?? undefined,
ownerId: record.ownerId ?? undefined,
thumbnailUrl: record.thumbnailUrl ?? undefined,
thumbnailLocal: record.thumbnailLocal ?? undefined,
isDeviceFile: record.source === 'local' && !record.backendId,
};
}
export function useSearch(query: string) {
const results = useMemo(() => {
if (!query.trim()) return [];
const records = fileStore.searchFts(query);
if (records.length === 0) {
const fallback = fileStore.search(query);
return fallback.map((r) => recordToUnifiedItem(r)!).filter(Boolean);
}
return records.map((r) => recordToUnifiedItem(r)!).filter(Boolean);
}, [query]);
return {
data: results,
isLoading: false,
};
}
-47
View File
@@ -1,47 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { ShareEntry } from '../types';
function shareEndpoint(resourceId: string) {
return `/resources/${resourceId}/share`;
}
export function useShares(resourceId: string) {
return useQuery({
queryKey: ['shares', resourceId],
queryFn: () => apiClient.get<ShareEntry[]>(shareEndpoint(resourceId)),
enabled: !!resourceId,
});
}
export function useGrantShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceId, subjectUserId, role }: { resourceId: string; subjectUserId: string; role: string }) =>
apiClient.post(shareEndpoint(resourceId), { subject_user_id: subjectUserId, role }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] });
},
});
}
export function useRevokeShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceId, userId }: { resourceId: string; userId: string }) =>
apiClient.delete(`${shareEndpoint(resourceId)}/${userId}`),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] });
},
});
}
export function useCheckAccess(resourceId: string) {
return useQuery({
queryKey: ['access', resourceId],
queryFn: () => apiClient.get<{ role: string; access: boolean }>(`/resources/${resourceId}/access`),
enabled: !!resourceId,
});
}
-23
View File
@@ -1,23 +0,0 @@
import { useCallback, useRef } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { SyncQueueItem } from '../types';
export function useSyncPull() {
const isRunning = useRef(false);
const pull = useCallback(async (locationId?: string) => {
if (isRunning.current) return { items: [] };
isRunning.current = true;
try {
const body = locationId ? { location_id: locationId } : {};
const result = await apiClient.post<SyncQueueItem[]>(ENDPOINTS.SYNC_PULL, body);
return { items: result };
} finally {
isRunning.current = false;
}
}, []);
return { pull };
}
-23
View File
@@ -1,23 +0,0 @@
import { useCallback, useRef } from 'react';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
export function useSyncPush() {
const isRunning = useRef(false);
const push = useCallback(async (locationId: string) => {
if (isRunning.current) return { pending: 0 };
isRunning.current = true;
try {
const result = await apiClient.post<{ pending: number; message: string }>(ENDPOINTS.SYNC_PUSH, {
location_id: locationId,
});
return { pending: result.pending };
} finally {
isRunning.current = false;
}
}, []);
return { push };
}
-133
View File
@@ -1,133 +0,0 @@
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
import { createMMKV } from 'react-native-mmkv';
import { fileStore } from '../services/fileStore';
export type SyncProgress = {
files: Array<{ id: string; name: string }>;
currentIndex: number;
} | null;
const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
const PROGRESS_KEY = 'sync_progress';
let syncRunning = false;
let cancelRequested = false;
const listeners = new Set<() => void>();
function notify() {
listeners.forEach((l) => l());
}
export function getIsSyncing(): boolean {
return syncStateStorage.getString('is_syncing') === 'true';
}
export function setIsSyncing(value: boolean) {
syncStateStorage.set('is_syncing', value ? 'true' : 'false');
notify();
}
export function getSyncProgress(): SyncProgress {
return parseProgress(syncStateStorage.getString(PROGRESS_KEY));
}
function parseProgress(raw: string | undefined): SyncProgress {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { files: Array<{ id: string; name: string }>; currentIndex: number };
if (!parsed || !Array.isArray(parsed.files)) return null;
return parsed;
} catch {
return null;
}
}
export function setSyncProgress(value: SyncProgress) {
if (value === null) {
syncStateStorage.remove(PROGRESS_KEY);
} else {
syncStateStorage.set(PROGRESS_KEY, JSON.stringify(value));
}
notify();
}
export function isSyncLoopRunning(): boolean {
return syncRunning;
}
export function setSyncLoopRunning(value: boolean) {
syncRunning = value;
}
export function requestSyncCancel() {
cancelRequested = true;
}
export function isSyncCancelRequested(): boolean {
return cancelRequested;
}
export function consumeSyncCancel(): boolean {
const value = cancelRequested;
cancelRequested = false;
return value;
}
export function resetSyncState() {
syncStateStorage.remove('is_syncing');
syncStateStorage.remove(PROGRESS_KEY);
syncRunning = false;
cancelRequested = false;
notify();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
let cachedIsSyncing = getIsSyncing();
let lastIsSyncingRaw: string | undefined;
function getIsSyncingSnapshot(): boolean {
const raw = syncStateStorage.getString('is_syncing');
if (raw !== lastIsSyncingRaw) {
lastIsSyncingRaw = raw;
cachedIsSyncing = raw === 'true';
}
return cachedIsSyncing;
}
let cachedProgress = getSyncProgress();
let lastProgressRaw: string | undefined;
function getSyncProgressSnapshot(): SyncProgress {
const raw = syncStateStorage.getString(PROGRESS_KEY);
if (raw !== lastProgressRaw) {
lastProgressRaw = raw;
cachedProgress = parseProgress(raw);
}
return cachedProgress;
}
export function useSyncQueue() {
const isSyncing = useSyncExternalStore(subscribe, getIsSyncingSnapshot, getIsSyncingSnapshot);
const [pendingCount, setPendingCount] = useState(0);
const refresh = useCallback(() => {
setPendingCount(fileStore.countPendingSync());
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 5000);
return () => clearInterval(interval);
}, [refresh]);
return { pendingCount, isSyncing, refresh };
}
export function useSyncProgress() {
const syncProgress = useSyncExternalStore(subscribe, getSyncProgressSnapshot, getSyncProgressSnapshot);
return { syncProgress };
}
-79
View File
@@ -1,79 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { File, UploadType } from 'expo-file-system';
import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, UploadError } from '../types';
export type UploadFile = { uri: string; type: string; name: string };
export type UploadResult = { name: string; id: string };
export function useUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (files: UploadFile[]) => {
const results = await Promise.allSettled(
files.map(async (file) => {
const fsFile = new File(file.uri);
const headers: Record<string, string> = {};
const token = apiClient.getAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
httpMethod: 'POST',
uploadType: UploadType.MULTIPART,
fieldName: 'file',
mimeType: file.type,
headers,
});
if (result.status >= 400) {
let serverMessage = 'Erreur serveur';
let serverCode: string | undefined;
try {
const body: ApiError = JSON.parse(result.body);
serverMessage = body.error?.message || serverMessage;
serverCode = body.error?.code;
} catch {
serverMessage = result.body || serverMessage;
}
throw new UploadError(file.name, result.status, serverMessage, serverCode);
}
const body = JSON.parse(result.body);
const items = body.data ?? body;
const item = Array.isArray(items) ? items[0] : items;
return item as UploadResult;
})
);
const uploaded: UploadResult[] = [];
const errors: UploadError[] = [];
results.forEach((r, i) => {
if (r.status === 'fulfilled') {
uploaded.push(r.value);
} else {
const reason = r.reason;
if (reason instanceof UploadError) {
errors.push(reason);
} else {
errors.push(new UploadError(files[i].name, 0, reason?.message || 'Upload failed'));
}
}
});
if (errors.length > 0 && uploaded.length === 0) {
throw errors[0];
}
return { uploaded, errors };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
},
});
}
-17
View File
@@ -1,17 +0,0 @@
import { useSyncExternalStore } from 'react';
import { uploadQueue, UploadFile } from '../services/uploadQueue';
export function useUploadQueue() {
const tasks = useSyncExternalStore(
uploadQueue.subscribe.bind(uploadQueue),
uploadQueue.getTasks.bind(uploadQueue),
);
return {
tasks,
enqueue: (files: UploadFile[]) => uploadQueue.enqueue(files),
cancel: (id: string) => uploadQueue.cancel(id),
retry: (id: string) => uploadQueue.retry(id),
retryAll: () => uploadQueue.retryAll(),
};
}