background fetch

This commit is contained in:
m
2026-07-29 19:24:13 +02:00
parent 3d63dc1987
commit 00084758bf
9 changed files with 474 additions and 302 deletions
-5
View File
@@ -118,10 +118,6 @@ export function HomeScreen() {
const { pendingCount, isSyncing } = useSyncQueue();
useAutoSync();
const handleUploadComplete = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['resources'] });
}, [queryClient]);
const itemSize = (SCREEN_WIDTH - PADDING_H * 2 - (numColumns - 1) * ITEM_GAP) / numColumns;
const loadMore = useCallback(() => {
@@ -659,7 +655,6 @@ export function HomeScreen() {
<UploadModal
visible={uploadModalVisible}
onClose={() => setUploadModalVisible(false)}
onUploadComplete={handleUploadComplete}
/>
<SettingsModal
+102 -32
View File
@@ -5,23 +5,63 @@ import {
StyleSheet,
TouchableOpacity,
Dimensions,
ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { ONBOARDING_STEPS, CURRENT_ONBOARDING_VERSION, type OnboardingStep } from '../config/onboarding';
import { onboardingStorage } from '../services/onboardingStorage';
import { scanSubdirectories } from '../hooks/useDeviceFiles';
import { safDirectory } from '../services/safDirectory';
import * as FileSystem from 'expo-file-system/legacy';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const ICON_MAP: Record<string, keyof typeof MaterialIcons.glyphMap> = {
'waving-hand': 'waving-hand',
'folder-off': 'folder-off',
'create-new-folder': 'create-new-folder',
};
type ScanState = 'idle' | 'scanning' | 'done';
function stepIcon(step: OnboardingStep): keyof typeof MaterialIcons.glyphMap {
if (step.icon === 'waving-hand') return 'waving-hand';
if (step.icon === 'create-new-folder') return 'create-new-folder';
if (step.icon === 'check-circle') return 'check-circle';
if (step.icon === 'folder-off') return 'folder-off';
return 'info';
}
function ScanProgressView() {
return (
<>
<View style={styles.iconContainer}>
<ActivityIndicator size={48} color="#1976D2" />
</View>
<Text style={styles.title}>Scan en cours...</Text>
<Text style={styles.description}>
Dot. explore les sous-dossiers de votre stockage. Cela peut prendre quelques secondes.
</Text>
</>
);
}
function ScanDoneView({ folderCount }: { folderCount: number }) {
return (
<>
<View style={[styles.iconContainer, { backgroundColor: '#E8F5E9' }]}>
<MaterialIcons name="check-circle" size={64} color="#43A047" />
</View>
<Text style={styles.title}>Scan terminé !</Text>
<Text style={styles.description}>
{folderCount > 1
? `${folderCount} dossiers découverts et ajoutés à votre espace Dot.`
: '1 dossier ajouté à votre espace Dot.'}
</Text>
</>
);
}
export function OnboardingScreen() {
const navigation = useNavigation();
const [currentIndex, setCurrentIndex] = useState(0);
const [scanState, setScanState] = useState<ScanState>('idle');
const [folderCount, setFolderCount] = useState(0);
const pendingSteps = onboardingStorage.getPendingSteps();
const step = pendingSteps[currentIndex];
@@ -31,19 +71,33 @@ export function OnboardingScreen() {
navigation.reset({ index: 0, routes: [{ name: 'Home' as never }] });
}, [navigation]);
const handlePickDirectory = useCallback(async () => {
const { safDirectory } = await import('../services/safDirectory');
const FileSystem = await import('expo-file-system/legacy');
const handleRecursiveScan = useCallback(async () => {
try {
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!result.granted) return;
const dirUri = result.directoryUri;
const parts = dirUri.split('/');
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Stockage');
setScanState('scanning');
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 }))
);
}
setFolderCount(1 + subdirs.length);
setScanState('done');
safDirectory.setDiscovered();
} catch (err) {
console.error('[Onboarding] pickDirectory error:', err);
console.error('[Onboarding] recursive scan error:', err);
setScanState('idle');
}
}, []);
@@ -53,6 +107,8 @@ export function OnboardingScreen() {
if (currentIndex < pendingSteps.length - 1) {
setCurrentIndex(currentIndex + 1);
setScanState('idle');
setFolderCount(0);
} else {
complete();
}
@@ -67,6 +123,8 @@ export function OnboardingScreen() {
return null;
}
const isScanStep = step.action?.type === 'recursive_scan';
return (
<View style={styles.container}>
<View style={styles.skipContainer}>
@@ -76,22 +134,25 @@ export function OnboardingScreen() {
</View>
<View style={styles.content}>
<View style={styles.iconContainer}>
<MaterialIcons
name={ICON_MAP[step.icon] ?? 'info'}
size={64}
color="#1976D2"
/>
</View>
{scanState === 'scanning' && isScanStep ? (
<ScanProgressView />
) : scanState === 'done' && isScanStep ? (
<ScanDoneView folderCount={folderCount} />
) : (
<>
<View style={styles.iconContainer}>
<MaterialIcons name={stepIcon(step)} size={64} color="#1976D2" />
</View>
<Text style={styles.title}>{step.title}</Text>
<Text style={styles.description}>{step.description}</Text>
<Text style={styles.title}>{step.title}</Text>
<Text style={styles.description}>{step.description}</Text>
{step.action?.type === 'pick_directory' && (
<TouchableOpacity style={styles.actionBtn} onPress={handlePickDirectory}>
<MaterialIcons name="folder-open" size={20} color="#fff" />
<Text style={styles.actionBtnText}>{step.action.label}</Text>
</TouchableOpacity>
{isScanStep && scanState === 'idle' && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRecursiveScan}>
<MaterialIcons name="folder-open" size={20} color="#fff" />
<Text style={styles.actionBtnText}>{step.action?.label ?? 'Choisir un dossier'}</Text>
</TouchableOpacity>
)}
</>
)}
</View>
@@ -105,12 +166,21 @@ export function OnboardingScreen() {
))}
</View>
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextBtnText}>
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
</Text>
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
{(!isScanStep || scanState === 'done') && (
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextBtnText}>
{currentIndex < pendingSteps.length - 1 ? 'Suivant' : 'Commencer'}
</Text>
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
)}
{isScanStep && scanState === 'idle' && (
<TouchableOpacity style={styles.nextBtn} onPress={handleNext}>
<Text style={styles.nextBtnText}>Passer cette étape</Text>
<MaterialIcons name="arrow-forward" size={20} color="#fff" />
</TouchableOpacity>
)}
</View>
</View>
);
+141 -34
View File
@@ -6,6 +6,7 @@ import {
FlatList,
TouchableOpacity,
ActivityIndicator,
Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
@@ -14,6 +15,8 @@ import { safDirectory } from '../services/safDirectory';
import { useSyncQueue } from '../hooks/useSyncQueue';
import { useAutoSync } from '../hooks/useAutoSync';
import { useSyncPush } from '../hooks/useSyncPush';
import { useUploadQueue } from '../hooks/useUploadQueue';
import { UploadTask } from '../services/uploadQueue';
function formatSize(bytes: number): string {
if (bytes === 0) return '';
@@ -22,11 +25,25 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
function uploadStatusIcon(task: UploadTask) {
switch (task.status) {
case 'pending':
return <MaterialIcons name="schedule" size={20} color="#FFA000" />;
case 'uploading':
return <ActivityIndicator size="small" color="#1976D2" />;
case 'done':
return <MaterialIcons name="check-circle" size={20} color="#4CAF50" />;
case 'error':
return <MaterialIcons name="error" size={20} color="#E53935" />;
}
}
export function SyncDetailScreen() {
const insets = useSafeAreaInsets();
const { pendingCount, isSyncing, refresh } = useSyncQueue();
const { triggerSync } = useAutoSync();
const { push } = useSyncPush();
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
const pendingFiles = useMemo(() => {
return fileStore.getPendingSync();
@@ -34,74 +51,134 @@ export function SyncDetailScreen() {
const handleSyncAll = useCallback(async () => {
await triggerSync();
try {
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
const serverId = await getStoredDeviceServerId();
if (serverId) {
await push(serverId);
}
} catch {
// push notification is best-effort
}
} catch { }
refresh();
}, [triggerSync, push, refresh]);
const renderItem = useCallback(({ item }: { item: FileRecord }) => (
<View style={styles.fileRow}>
<MaterialIcons name="insert-drive-file" size={20} color="#999" />
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{item.name}</Text>
<Text style={styles.fileMeta}>
{formatSize(item.size)}
{item.mimeType ? ` · ${item.mimeType.split('/').pop()}` : ''}
</Text>
</View>
<View style={styles.localBadge}>
<MaterialIcons name="phone-android" size={14} color="#757575" />
</View>
</View>
), []);
const handleTaskPress = useCallback((task: UploadTask) => {
if (task.status !== 'error') return;
Alert.alert(
'Erreur d\'upload',
task.error || 'Erreur inconnue',
[
{ text: 'Annuler', style: 'cancel' },
{ text: 'Réessayer', onPress: () => retry(task.id) },
],
);
}, [retry]);
const hasUploads = uploadTasks.length > 0;
const hasPending = pendingFiles.length > 0;
const hasContent = hasUploads || hasPending;
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
return (
<View style={styles.container}>
{pendingCount > 0 && (
{(hasPending || uploadErrors.length > 0) && (
<TouchableOpacity
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
onPress={handleSyncAll}
onPress={uploadErrors.length > 0 && hasPending ? retryAll : handleSyncAll}
disabled={isSyncing}
>
{isSyncing ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<MaterialIcons name="cloud-upload" size={20} color="#fff" />
<MaterialIcons
name={uploadErrors.length > 0 ? "refresh" : "cloud-upload"}
size={20}
color="#fff"
/>
)}
<Text style={styles.syncBtnText}>
{isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`}
{isSyncing ? 'Synchronisation...'
: uploadErrors.length > 0 && hasPending
? 'Tout réessayer'
: hasPending
? `Synchroniser (${pendingCount})`
: `Réessayer (${uploadErrors.length})`}
</Text>
</TouchableOpacity>
)}
{pendingFiles.length === 0 ? (
{!hasContent ? (
<View style={styles.empty}>
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
<Text style={styles.emptyTitle}>Tout est synchronisé</Text>
<Text style={styles.emptySubtitle}>
Aucun fichier en attente d'upload
Aucun fichier en attente
</Text>
</View>
) : (
<FlatList
data={pendingFiles}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
ListHeaderComponent={
<Text style={styles.headerText}>
{pendingFiles.length} fichier{pendingFiles.length > 1 ? 's' : ''} en attente
</Text>
data={[
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []),
...pendingFiles.map((f) => ({ type: 'file' as const, data: f })),
]}
keyExtractor={(item) =>
item.type === 'section' ? item.label
: item.type === 'upload' ? item.data.id
: item.data.id
}
renderItem={({ item }) => {
if (item.type === 'section') {
return <Text style={styles.headerText}>{item.label}</Text>;
}
if (item.type === 'upload') {
const task = item.data;
return (
<TouchableOpacity
style={[styles.fileRow, task.status === 'error' && styles.fileRowError]}
onPress={() => handleTaskPress(task)}
activeOpacity={task.status === 'error' ? 0.6 : 1}
>
{uploadStatusIcon(task)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{task.file.name}</Text>
{task.status === 'uploading' && (
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${task.progress}%` }]} />
</View>
)}
{task.status === 'error' && task.error && (
<Text style={styles.errorText} numberOfLines={1}>{task.error}</Text>
)}
{task.status === 'done' && (
<Text style={styles.doneText}>Upload terminé</Text>
)}
{task.status === 'pending' && (
<Text style={styles.pendingText}>En attente</Text>
)}
</View>
</TouchableOpacity>
);
}
const file = item.data;
return (
<View style={styles.fileRow}>
<MaterialIcons name="insert-drive-file" size={20} color="#999" />
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
<Text style={styles.fileMeta}>
{formatSize(file.size)}
{file.mimeType ? ` · ${file.mimeType.split('/').pop()}` : ''}
</Text>
</View>
<View style={styles.localBadge}>
<MaterialIcons name="phone-android" size={14} color="#757575" />
</View>
</View>
);
}}
contentContainerStyle={styles.list}
/>
)}
</View>
@@ -170,6 +247,36 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
fileRowError: {
backgroundColor: '#FFF0F0',
},
progressBar: {
height: 4,
backgroundColor: '#E0E0E0',
borderRadius: 2,
marginTop: 6,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#1976D2',
borderRadius: 2,
},
errorText: {
fontSize: 12,
color: '#E53935',
marginTop: 2,
},
doneText: {
fontSize: 12,
color: '#4CAF50',
marginTop: 2,
},
pendingText: {
fontSize: 12,
color: '#FFA000',
marginTop: 2,
},
empty: {
flex: 1,
justifyContent: 'center',
+16 -165
View File
@@ -1,132 +1,18 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import { useUpload, UploadFile } from '../hooks/useUpload';
import { usePollOcr, SseOcrListener } from '../hooks/usePollOcr';
import { UploadProgress } from './UploadProgress';
import { UploadError } from '../types';
import { apiClient } from '../api/client';
import { ENDPOINTS } from '../constants/api';
import { UploadFile } from '../services/uploadQueue';
import { useUploadQueue } from '../hooks/useUploadQueue';
interface UploadModalProps {
visible: boolean;
onClose: () => void;
onUploadComplete?: () => void;
}
function getUploadErrorMessage(err: UploadError): string {
switch (err.status) {
case 400:
return `${err.fileName} : format invalide (${err.message})`;
case 404:
return `${err.fileName} : endpoint introuvable`;
case 413:
return `${err.fileName} : fichier trop volumineux`;
case 500:
return `${err.fileName} : erreur serveur (${err.message})`;
case 0:
return `${err.fileName} : impossible de contacter le serveur`;
default:
return `${err.fileName} : erreur ${err.status} (${err.message})`;
}
}
export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalProps) {
const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = useState<string>();
const [uploadedCount, setUploadedCount] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const upload = useUpload();
const { pollOcr } = usePollOcr();
const canClose = status === 'idle' || status === 'success' || status === 'error';
const checkDupBeforeUpload = useCallback(async (files: UploadFile[]): Promise<UploadFile[]> => {
const toUpload: UploadFile[] = [];
for (const file of files) {
try {
const result = await apiClient.post<{ data: { duplicates: Array<{ id: string; name: string }>; count: number } }>(
ENDPOINTS.DEDUP_CHECK,
{ name: file.name, size: 0 },
);
const duplicates = result.data?.duplicates ?? [];
if (duplicates.length > 0) {
const names = duplicates.map((d: { name: string }) => d.name).join(', ');
let proceed = false;
await new Promise<void>((resolve) => {
Alert.alert(
'Fichier existant',
`"${file.name}" existe déjà sur le serveur (${names}).\nUploader quand même ?`,
[
{ text: 'Ignorer', style: 'cancel', onPress: () => resolve() },
{ text: 'Uploader', onPress: () => { proceed = true; resolve(); } },
],
);
});
if (proceed) toUpload.push(file);
} else {
toUpload.push(file);
}
} catch {
toUpload.push(file);
}
}
return toUpload;
}, []);
const doUpload = async (files: UploadFile[]) => {
setStatus('uploading');
setUploadedCount(0);
setTotalCount(files.length);
setError(undefined);
try {
const deduped = await checkDupBeforeUpload(files);
if (deduped.length === 0) {
setStatus('success');
setUploadedCount(files.length);
return;
}
setTotalCount(deduped.length);
const response = await upload.mutateAsync(deduped);
setUploadedCount(response.uploaded.length);
if (response.uploaded.length > 0) {
setStatus('processing');
const results = await Promise.allSettled(
response.uploaded.map((f) => pollOcr(f.id)),
);
const completed = results.filter(
(r) => r.status === 'fulfilled' && r.value.status === 'completed',
).length;
if (completed > 0) {
setStatus('success');
}
}
if (response.errors.length > 0) {
setStatus('error');
const messages = response.errors.map((e) => getUploadErrorMessage(e));
setError(
`${response.uploaded.length}/${totalCount} uploadés.\n${messages.join('\n')}`
);
} else {
setStatus('success');
}
} catch (err) {
setStatus('error');
if (err instanceof UploadError) {
setError(getUploadErrorMessage(err));
} else if (err instanceof Error) {
setError(err.message);
} else {
setError("Erreur inconnue lors de l'upload");
}
}
};
export function UploadModal({ visible, onClose }: UploadModalProps) {
const { enqueue } = useUploadQueue();
const pickImages = async () => {
try {
@@ -150,8 +36,9 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
name: asset.fileName || 'photo.jpg',
}));
await doUpload(files);
} catch (err) {
enqueue(files);
onClose();
} catch {
Alert.alert('Erreur', "Impossible d'accéder à la galerie");
}
};
@@ -171,73 +58,38 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
name: asset.name,
}));
await doUpload(files);
} catch (err) {
enqueue(files);
onClose();
} catch {
Alert.alert('Erreur', "Impossible de sélectionner des documents");
}
};
const handleClose = useCallback(() => {
if (!canClose) return;
setStatus('idle');
setError(undefined);
setUploadedCount(0);
setTotalCount(0);
if (status === 'success') {
onUploadComplete?.();
}
onClose();
}, [canClose, status, onClose, onUploadComplete]);
useEffect(() => {
if (status === 'success') {
const timer = setTimeout(() => {
setStatus('idle');
setError(undefined);
setUploadedCount(0);
setTotalCount(0);
onUploadComplete?.();
onClose();
}, 2000);
return () => clearTimeout(timer);
}
}, [status, onClose, onUploadComplete]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={canClose ? handleClose : undefined}
onPress={onClose}
>
<TouchableOpacity activeOpacity={1} style={styles.container} onPress={() => {}}>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>Ajouter des fichiers</Text>
{canClose && (
<TouchableOpacity onPress={handleClose} style={styles.closeBtn}>
<MaterialIcons name="close" size={22} color="#999" />
</TouchableOpacity>
)}
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<MaterialIcons name="close" size={22} color="#999" />
</TouchableOpacity>
</View>
<UploadProgress
status={status}
error={error}
uploadedCount={uploadedCount}
totalCount={totalCount}
/>
<TouchableOpacity
style={styles.photoButton}
onPress={pickImages}
disabled={status === 'uploading' || status === 'processing'}
>
<MaterialIcons name="photo-library" size={20} color="#fff" />
<Text style={styles.buttonText}>Sélectionner des photos</Text>
@@ -246,7 +98,6 @@ export function UploadModal({ visible, onClose, onUploadComplete }: UploadModalP
<TouchableOpacity
style={styles.docButton}
onPress={pickDocuments}
disabled={status === 'uploading' || status === 'processing'}
>
<MaterialIcons name="description" size={20} color="#fff" />
<Text style={styles.buttonText}>Sélectionner des documents</Text>
+8 -15
View File
@@ -1,7 +1,7 @@
export const CURRENT_ONBOARDING_VERSION = 1;
export const CURRENT_ONBOARDING_VERSION = 2;
export type OnboardingAction = {
type: 'pick_directory';
type: 'pick_directory' | 'recursive_scan';
label: string;
};
@@ -18,25 +18,18 @@ export type OnboardingStep = {
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'welcome',
version: 1,
version: 2,
title: 'Bienvenue sur Dot.',
description: 'Votre espace document personnel, toujours accessible.',
icon: 'waving-hand',
},
{
id: 'folder_explanation',
version: 1,
title: 'Pourquoi un dossier dédié ?',
description: 'Depuis Android 13, les applications ne peuvent plus accéder librement au dossier Téléchargements (par sécurité).\n\nDot. utilise un mécanisme spécial (SAF) pour lire vos fichiers. En créant un dossier dédié, vous autorisez l\'application à y accéder en toute sécurité.\n\n→ Déplacez vos documents dans ce dossier et ils apparaîtront automatiquement dans Dot.',
icon: 'folder-off',
},
{
id: 'create_folder',
version: 1,
title: 'Créez votre dossier',
description: 'Sélectionnez ou créez un dossier dans le sélecteur ci-dessous.',
id: 'pick_root_folder',
version: 2,
title: 'Choisissez votre dossier principal',
description: 'Sélectionnez la racine de votre stockage dans le sélecteur.\n\nDot. va scanner automatiquement tous les sous-dossiers (photos, téléchargements, documents…).',
icon: 'create-new-folder',
action: { type: 'pick_directory', label: 'Sélectionner un dossier' },
action: { type: 'recursive_scan', label: 'Choisir le dossier principal' },
condition: 'has_no_folders',
},
];
+1 -1
View File
@@ -114,7 +114,7 @@ async function scanMediaAlbum(folder: StoredFolder): Promise<DeviceFile[]> {
}
}
async function scanSubdirectories(
export async function scanSubdirectories(
baseUri: string,
depth: number = 0,
maxDepth: number = 3,
+2 -50
View File
@@ -1,21 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
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';
export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' };
type OcrState = Record<string, { resolve: (res: OcrPollResult) => void; timeout: ReturnType<typeof setTimeout> }>;
let globalOcrState: OcrState = {};
let globalSetState: React.Dispatch<React.SetStateAction<number>> | null = null;
function handleOcrDone(resourceId: string) {
const entry = globalOcrState[resourceId];
if (!entry) return;
clearTimeout(entry.timeout);
apiClient
.get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
.then((detail) => {
@@ -23,15 +12,8 @@ function handleOcrDone(resourceId: string) {
if (ocrText) {
fileStore.updatePartial(resourceId, { ocrText });
}
entry.resolve({ resourceId, status: 'completed' });
})
.catch(() => {
entry.resolve({ resourceId, status: 'failed' });
})
.finally(() => {
delete globalOcrState[resourceId];
globalSetState?.(Date.now());
});
.catch(() => {});
}
export function SseOcrListener() {
@@ -44,33 +26,3 @@ export function SseOcrListener() {
return null;
}
export function usePollOcr() {
const [, forceUpdate] = useState(0);
useEffect(() => {
globalSetState = forceUpdate;
return () => {
globalSetState = null;
};
}, []);
const pollOcr = useCallback(async (resourceId: string): Promise<OcrPollResult> => {
const existing = globalOcrState[resourceId];
if (existing) {
return new Promise((resolve) => existing.resolve = resolve);
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
delete globalOcrState[resourceId];
resolve({ resourceId, status: 'timeout' });
}, 120_000);
globalOcrState[resourceId] = { resolve, timeout };
globalSetState?.(Date.now());
});
}, []);
return { pollOcr };
}
+17
View File
@@ -0,0 +1,17 @@
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(),
};
}
+187
View File
@@ -0,0 +1,187 @@
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 type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
export type UploadTask = {
id: string;
file: UploadFile;
status: UploadTaskStatus;
progress: number;
result?: UploadResult;
error?: string;
createdAt: number;
updatedAt: number;
};
type Listener = () => void;
let nextId = 0;
function genId() {
nextId++;
return `upload_${Date.now()}_${nextId}`;
}
class UploadQueue {
private tasks: UploadTask[] = [];
private listeners = new Set<Listener>();
private concurrency = 3;
private active = 0;
private cleanupTimer: ReturnType<typeof setTimeout> | null = null;
getTasks(): UploadTask[] {
return this.tasks;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify() {
this.listeners.forEach((l) => l());
}
enqueue(files: UploadFile[]) {
const now = Date.now();
for (const file of files) {
this.tasks.push({
id: genId(),
file,
status: 'pending',
progress: 0,
createdAt: now,
updatedAt: now,
});
}
this.notify();
this.processNext();
}
cancel(id: string) {
const task = this.tasks.find((t) => t.id === id);
if (!task || task.status === 'done') return;
task.status = 'error';
task.error = 'Annulé';
task.updatedAt = Date.now();
this.notify();
}
retry(id: string) {
const task = this.tasks.find((t) => t.id === id);
if (!task || task.status !== 'error') return;
task.status = 'pending';
task.progress = 0;
task.error = undefined;
task.result = undefined;
task.updatedAt = Date.now();
this.notify();
this.processNext();
}
retryAll() {
for (const task of this.tasks) {
if (task.status === 'error') {
task.status = 'pending';
task.progress = 0;
task.error = undefined;
task.result = undefined;
task.updatedAt = Date.now();
}
}
this.notify();
this.processNext();
}
private processNext() {
while (this.active < this.concurrency) {
const next = this.tasks.find((t) => t.status === 'pending');
if (!next) break;
this.active++;
next.status = 'uploading';
next.updatedAt = Date.now();
this.notify();
this.runTask(next);
}
}
private async runTask(task: UploadTask) {
try {
const fsFile = new File(task.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: task.file.type,
headers,
});
if (result.status >= 400) {
let serverMessage = 'Erreur serveur';
try {
const body: ApiError = JSON.parse(result.body);
serverMessage = body.error?.message || serverMessage;
} catch {
serverMessage = result.body || serverMessage;
}
throw new UploadError(task.file.name, result.status, serverMessage);
}
const body = JSON.parse(result.body);
const items = body.data ?? body;
const item = Array.isArray(items) ? items[0] : items;
task.status = 'done';
task.progress = 100;
task.result = item as UploadResult;
task.updatedAt = Date.now();
this.notify();
this.scheduleCleanup();
} catch (err) {
task.status = 'error';
task.error =
err instanceof UploadError
? `${err.fileName} : ${err.message}`
: err instanceof Error
? err.message
: 'Erreur inconnue';
task.updatedAt = Date.now();
this.notify();
} finally {
this.active--;
this.notify();
this.processNext();
}
}
private scheduleCleanup() {
if (this.cleanupTimer) return;
this.cleanupTimer = setTimeout(() => {
this.cleanupTimer = null;
const now = Date.now();
const before = this.tasks.length;
this.tasks = this.tasks.filter(
(t) => t.status !== 'done' || now - t.updatedAt < 5000,
);
if (this.tasks.length !== before) {
this.notify();
}
if (this.tasks.some((t) => t.status === 'done')) {
this.scheduleCleanup();
}
}, 6000);
}
}
export const uploadQueue = new UploadQueue();