This commit is contained in:
m
2026-08-25 17:04:17 +02:00
parent 7e7d669d63
commit a791df26bf
8 changed files with 130 additions and 38 deletions
+2
View File
@@ -28,9 +28,11 @@ import { onboardingStorage } from './services/onboardingStorage';
import { initDB } from './services/fileStore'; import { initDB } from './services/fileStore';
import { migrateFromLegacy } from './services/fileStore/migrate'; import { migrateFromLegacy } from './services/fileStore/migrate';
import { createMMKVPersister } from './services/mmkvPersister'; import { createMMKVPersister } from './services/mmkvPersister';
import { setIsSyncing } from './hooks/useSyncQueue';
initDB(); initDB();
migrateFromLegacy(); migrateFromLegacy();
setIsSyncing(false);
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
+7 -4
View File
@@ -107,7 +107,7 @@ export function HomeScreen() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 250); const debouncedSearch = useDebounce(searchQuery, 250);
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true }); const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
const [mediaFilter, setMediaFilter] = useState<MediaFilter>('all'); const [mediaFilter, setMediaFilter] = useState<MediaFilter>('documents');
const [keyboardOpen, setKeyboardOpen] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [numColumns, setNumColumns] = useState(3); const [numColumns, setNumColumns] = useState(3);
@@ -149,6 +149,7 @@ export function HomeScreen() {
const totalFiles = data?.meta?.total ?? 0; const totalFiles = data?.meta?.total ?? 0;
const loadedFiles = data?.data?.length ?? 0; const loadedFiles = data?.data?.length ?? 0;
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles; const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
const isFiltering = mediaFilter !== 'all' || !!debouncedSearch.trim();
useEffect(() => { useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
@@ -233,6 +234,8 @@ export function HomeScreen() {
}); });
}, [mediaFilteredFiles, uploadGhostItems]); }, [mediaFilteredFiles, uploadGhostItems]);
const displayedCount = sortedFiles.length;
const fileIdToIndex = useMemo(() => { const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
sortedFiles.forEach((f, i) => map.set(f.id, i)); sortedFiles.forEach((f, i) => map.set(f.id, i));
@@ -541,12 +544,12 @@ export function HomeScreen() {
<ActivityIndicator size="small" color="#1976D2" /> <ActivityIndicator size="small" color="#1976D2" />
) : ( ) : (
<Text style={styles.loadMoreText}> <Text style={styles.loadMoreText}>
Charger plus ({loadedFiles}/{totalFiles}) Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
</Text> </Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
) : loadedFiles > 0 ? ( ) : displayedCount > 0 ? (
<Text style={styles.loadedAllText}>{loadedFiles} fichier{loadedFiles > 1 ? 's' : ''}</Text> <Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
) : null ) : null
} }
ListEmptyComponent={ ListEmptyComponent={
+77 -16
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useCallback } from 'react'; import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { import {
View, View,
Text, Text,
@@ -45,11 +45,28 @@ export function SyncDetailScreen() {
const { push } = useSyncPush(); const { push } = useSyncPush();
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue(); const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
const [listVersion, setListVersion] = useState(0);
const bumpList = useCallback(() => setListVersion((v) => v + 1), []);
useEffect(() => {
const interval = setInterval(bumpList, 5000);
return () => clearInterval(interval);
}, [bumpList]);
const pendingFiles = useMemo(() => { const pendingFiles = useMemo(() => {
return fileStore.getPendingSync(); return fileStore.getPendingSync();
}, []); }, [listVersion]);
const errorFiles = useMemo(() => {
return fileStore.getErrorFiles();
}, [listVersion]);
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
const handleSyncAll = useCallback(async () => { const handleSyncAll = useCallback(async () => {
for (const f of fileStore.getErrorFiles()) {
fileStore.resetSyncError(f.id);
}
await triggerSync(); await triggerSync();
try { try {
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration'); const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
@@ -59,7 +76,35 @@ export function SyncDetailScreen() {
} }
} catch { } } catch { }
refresh(); refresh();
}, [triggerSync, push, refresh]); bumpList();
}, [triggerSync, push, refresh, bumpList]);
const handleSyncButtonPress = useCallback(async () => {
if (uploadErrors.length > 0) {
retryAll();
}
await handleSyncAll();
}, [uploadErrors.length, retryAll, handleSyncAll]);
const handleErrorFilePress = useCallback((file: FileRecord) => {
Alert.alert(
'Fichier en erreur',
'Réessayer la synchronisation de ce fichier ?',
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Réessayer',
onPress: () => {
fileStore.resetSyncError(file.id);
triggerSync().finally(() => {
refresh();
bumpList();
});
},
},
],
);
}, [triggerSync, refresh, bumpList]);
const handleTaskPress = useCallback((task: UploadTask) => { const handleTaskPress = useCallback((task: UploadTask) => {
if (task.status !== 'error') return; if (task.status !== 'error') return;
@@ -75,16 +120,15 @@ export function SyncDetailScreen() {
const hasUploads = uploadTasks.length > 0; const hasUploads = uploadTasks.length > 0;
const hasPending = pendingFiles.length > 0; const hasPending = pendingFiles.length > 0;
const hasContent = hasUploads || hasPending; const hasErrors = errorFiles.length > 0;
const hasContent = hasUploads || hasPending || hasErrors;
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
return ( return (
<View style={styles.container}> <View style={styles.container}>
{(hasPending || uploadErrors.length > 0) && ( {(hasPending || hasErrors || uploadErrors.length > 0) && (
<TouchableOpacity <TouchableOpacity
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]} style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
onPress={uploadErrors.length > 0 && hasPending ? retryAll : handleSyncAll} onPress={handleSyncButtonPress}
disabled={isSyncing} disabled={isSyncing}
> >
{isSyncing ? ( {isSyncing ? (
@@ -98,11 +142,9 @@ export function SyncDetailScreen() {
)} )}
<Text style={styles.syncBtnText}> <Text style={styles.syncBtnText}>
{isSyncing ? 'Synchronisation...' {isSyncing ? 'Synchronisation...'
: uploadErrors.length > 0 && hasPending : uploadErrors.length > 0
? 'Tout réessayer' ? `Tout réessayer (${uploadErrors.length + (hasPending ? pendingCount : 0)})`
: hasPending : `Synchroniser (${pendingCount})`}
? `Synchroniser (${pendingCount})`
: `Réessayer (${uploadErrors.length})`}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -120,13 +162,13 @@ export function SyncDetailScreen() {
data={[ data={[
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []), ...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })), ...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
...(hasErrors ? [{ type: 'section', label: 'Fichiers en erreur' } as const] : []),
...errorFiles.map((f) => ({ type: 'error' as const, data: f })),
...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []), ...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []),
...pendingFiles.map((f) => ({ type: 'file' as const, data: f })), ...pendingFiles.map((f) => ({ type: 'file' as const, data: f })),
]} ]}
keyExtractor={(item) => keyExtractor={(item) =>
item.type === 'section' ? item.label item.type === 'section' ? item.label : item.data.id
: item.type === 'upload' ? item.data.id
: item.data.id
} }
renderItem={({ item }) => { renderItem={({ item }) => {
if (item.type === 'section') { if (item.type === 'section') {
@@ -161,6 +203,25 @@ export function SyncDetailScreen() {
</TouchableOpacity> </TouchableOpacity>
); );
} }
if (item.type === 'error') {
const file = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, styles.fileRowError]}
onPress={() => handleErrorFilePress(file)}
activeOpacity={0.6}
>
<MaterialIcons name="error" size={20} color="#E53935" />
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
<Text style={styles.errorText}>
Échec de synchronisation · toucher pour réessayer
</Text>
</View>
<MaterialIcons name="refresh" size={18} color="#E53935" />
</TouchableOpacity>
);
}
const file = item.data; const file = item.data;
return ( return (
<View style={styles.fileRow}> <View style={styles.fileRow}>
+7 -1
View File
@@ -5,6 +5,7 @@ import { File, UploadType } from 'expo-file-system';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { safDirectory, StoredFolder } from '../services/safDirectory'; import { safDirectory, StoredFolder } from '../services/safDirectory';
import { fileStore } from '../services/fileStore'; import { fileStore } from '../services/fileStore';
import { activeUploadUris } from '../services/uploadQueue';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError } from '../types'; import { ApiError } from '../types';
@@ -113,9 +114,12 @@ export function useAutoSync() {
setIsSyncing(true); setIsSyncing(true);
for (const entry of pendingFiles) { for (const entry of pendingFiles) {
const uri = entry.localUri;
if (!uri || activeUploadUris.has(uri)) continue;
activeUploadUris.add(uri);
try { try {
const uploaded = await uploadFile({ const uploaded = await uploadFile({
uri: entry.localUri!, uri,
type: entry.mimeType, type: entry.mimeType,
name: entry.name, name: entry.name,
}); });
@@ -134,6 +138,8 @@ export function useAutoSync() {
}); });
resetRetry(entry.id); resetRetry(entry.id);
} }
} finally {
activeUploadUris.delete(uri);
} }
} }
-11
View File
@@ -27,16 +27,6 @@ function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): Unif
}; };
} }
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles>): {
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 = 100) { export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
const queryKey = parentId const queryKey = parentId
? ['resources', parentId, page, limit] ? ['resources', parentId, page, limit]
@@ -65,7 +55,6 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb
thumbnailUrl: f.thumbnailUrl, thumbnailUrl: f.thumbnailUrl,
})), })),
); );
const children = fileStore.getChildrenByParent(parentId); const children = fileStore.getChildrenByParent(parentId);
return { return {
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean), data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
+9 -5
View File
@@ -28,21 +28,25 @@ export function useSyncQueue() {
return; return;
} }
const registry = fileStore.getPendingSync(); const allFolders = safDirectory.getAll();
const autoFolderIds = new Set(
allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id)
);
const registry = fileStore.getAllLocal();
let count = 0; let count = 0;
for (const entry of registry) { for (const entry of registry) {
if (entry.backendId || !entry.localUri) continue;
if (entry.syncStatus !== 'local' && entry.syncStatus !== 'error') continue;
if (globalMode === 'auto') { if (globalMode === 'auto') {
count++; count++;
} else { } else {
// mode manuel : uniquement les fichiers des dossiers en mode auto // mode manuel : uniquement les fichiers des dossiers en mode auto
if (!entry.parentResourceId) continue; if (!entry.parentResourceId || !autoFolderIds.has(entry.parentResourceId)) continue;
const folder = safDirectory.getAll().find((f) => f.id === entry.parentResourceId);
if (folder && folder.syncMode === 'auto') {
count++; count++;
} }
} }
}
setPendingCount(count); setPendingCount(count);
setIsSyncingState(getIsSyncing()); setIsSyncingState(getIsSyncing());
+7
View File
@@ -286,6 +286,13 @@ export const fileStore = {
return rowToRecord(row, getTagsForFile(row.id)); return rowToRecord(row, getTagsForFile(row.id));
}, },
getByLocalUri(localUri: string): FileRecord | null {
const d = getDb();
const row = d.select().from(files).where(eq(files.localUri, localUri)).get() as FileRow | undefined;
if (!row) return null;
return rowToRecord(row, getTagsForFile(row.id));
},
getRootFolders(): FileRecord[] { getRootFolders(): FileRecord[] {
const d = getDb(); const d = getDb();
const rows = d.select().from(files) const rows = d.select().from(files)
+20
View File
@@ -3,6 +3,7 @@ import { createMMKV } from 'react-native-mmkv';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { API_BASE_URL, ENDPOINTS } from '../constants/api'; import { API_BASE_URL, ENDPOINTS } from '../constants/api';
import { ApiError, UploadError } from '../types'; import { ApiError, UploadError } from '../types';
import { fileStore } from './fileStore';
export type UploadFile = { uri: string; type: string; name: string }; export type UploadFile = { uri: string; type: string; name: string };
export type UploadResult = { name: string; id: string }; export type UploadResult = { name: string; id: string };
@@ -10,6 +11,8 @@ export type UploadResult = { name: string; id: string };
export const UPLOAD_MAX_RETRIES = 3; export const UPLOAD_MAX_RETRIES = 3;
const BASE_RETRY_DELAY_MS = 1000; const BASE_RETRY_DELAY_MS = 1000;
export const activeUploadUris = new Set<string>();
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error'; export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
export type UploadTask = { export type UploadTask = {
@@ -222,6 +225,7 @@ class UploadQueue {
private async runTask(task: UploadTask) { private async runTask(task: UploadTask) {
let willRetry = false; let willRetry = false;
activeUploadUris.add(task.file.uri);
try { try {
const fsFile = new File(task.file.uri); const fsFile = new File(task.file.uri);
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -263,6 +267,7 @@ class UploadQueue {
task.progress = 100; task.progress = 100;
task.result = item as UploadResult; task.result = item as UploadResult;
task.updatedAt = Date.now(); task.updatedAt = Date.now();
this.linkResultToStore(task);
this.persist(); this.persist();
this.notify(); this.notify();
this.scheduleCleanup(); this.scheduleCleanup();
@@ -293,6 +298,7 @@ class UploadQueue {
} }
} finally { } finally {
this.active--; this.active--;
activeUploadUris.delete(task.file.uri);
this.notify(); this.notify();
if (!willRetry) { if (!willRetry) {
this.processNext(); this.processNext();
@@ -300,6 +306,20 @@ class UploadQueue {
} }
} }
private linkResultToStore(task: UploadTask) {
try {
const backendId = task.result?.id;
if (!backendId) return;
const entry = fileStore.getByLocalUri(task.file.uri);
if (!entry || entry.backendId) return;
fileStore.updatePartial(entry.id, {
backendId,
syncStatus: 'synced',
source: 'synced',
});
} catch {}
}
private scheduleCleanup() { private scheduleCleanup() {
if (this.cleanupTimer) return; if (this.cleanupTimer) return;
this.cleanupTimer = setTimeout(() => { this.cleanupTimer = setTimeout(() => {