sync details contain bugs
This commit is contained in:
+102
-32
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard, Alert, Modal, TextInput, ScrollView } from 'react-native';
|
import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard, Alert, Modal, TextInput, ScrollView, RefreshControl, ActivityIndicator } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
@@ -20,6 +20,7 @@ import { apiClient } from '../api/client';
|
|||||||
import { ENDPOINTS } from '../constants/api';
|
import { ENDPOINTS } from '../constants/api';
|
||||||
import { useLocalFiles } from '../hooks/useLocalFiles';
|
import { useLocalFiles } from '../hooks/useLocalFiles';
|
||||||
import { deleteAsync } from 'expo-file-system/legacy';
|
import { deleteAsync } from 'expo-file-system/legacy';
|
||||||
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
|
|
||||||
const NUM_COLUMNS = 3;
|
const NUM_COLUMNS = 3;
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
@@ -89,12 +90,12 @@ function formatDateLabel(key: string): string {
|
|||||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: (f: UnifiedFileItem) => void; onLongPress?: (f: UnifiedFileItem) => void; selected?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.gridItem, selected && styles.gridItemSelected]}
|
style={[styles.gridItem, selected && styles.gridItemSelected]}
|
||||||
onPress={onPress}
|
onPress={() => onPress?.(file)}
|
||||||
onLongPress={onLongPress}
|
onLongPress={() => onLongPress?.(file)}
|
||||||
delayLongPress={400}
|
delayLongPress={400}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@@ -131,8 +132,8 @@ const FileGroup = React.memo(function FileGroup({ groupFiles, selectedIds, onIte
|
|||||||
key={file.id}
|
key={file.id}
|
||||||
file={file}
|
file={file}
|
||||||
selected={selectedIds.has(file.id)}
|
selected={selectedIds.has(file.id)}
|
||||||
onPress={() => onItemPress(file)}
|
onPress={onItemPress}
|
||||||
onLongPress={() => onItemLongPress(file)}
|
onLongPress={onItemLongPress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -159,15 +160,19 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export function HomeScreen() {
|
export function HomeScreen() {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { data, isLoading, error } = useFiles();
|
const [page, setPage] = useState(1);
|
||||||
|
const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE);
|
||||||
const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
||||||
const deleteFile = useDeleteFile();
|
const deleteFile = useDeleteFile();
|
||||||
const freeLocalSpace = useFreeLocalSpace();
|
const freeLocalSpace = useFreeLocalSpace();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 250);
|
||||||
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true, tags: true });
|
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true, tags: true });
|
||||||
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());
|
||||||
@@ -185,6 +190,24 @@ export function HomeScreen() {
|
|||||||
const { pendingCount, isSyncing } = useSyncQueue();
|
const { pendingCount, isSyncing } = useSyncQueue();
|
||||||
useAutoSync();
|
useAutoSync();
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (isFetching) return;
|
||||||
|
const total = data?.meta?.total ?? 0;
|
||||||
|
const loaded = data?.data?.length ?? 0;
|
||||||
|
if (loaded < total) {
|
||||||
|
setPage((p) => p + 1);
|
||||||
|
}
|
||||||
|
}, [isFetching, data?.meta?.total, data?.data?.length]);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(() => {
|
||||||
|
setPage(1);
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const totalFiles = data?.meta?.total ?? 0;
|
||||||
|
const loadedFiles = data?.data?.length ?? 0;
|
||||||
|
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardOpen(false));
|
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardOpen(false));
|
||||||
@@ -213,12 +236,16 @@ export function HomeScreen() {
|
|||||||
const files = data?.data ?? [];
|
const files = data?.data ?? [];
|
||||||
|
|
||||||
const filteredFiles = useMemo(
|
const filteredFiles = useMemo(
|
||||||
() => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files,
|
() => debouncedSearch.trim() ? files.filter((f) => matchesQuery(f, debouncedSearch, filters)) : files,
|
||||||
[files, searchQuery, filters]
|
[files, debouncedSearch, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles);
|
const groups = useMemo(
|
||||||
const groupKeys = Object.keys(groups);
|
() => groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles),
|
||||||
|
[filteredFiles, groupByTags]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupKeys = useMemo(() => Object.keys(groups), [groups]);
|
||||||
|
|
||||||
const fileIdToIndex = useMemo(() => {
|
const fileIdToIndex = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
@@ -394,6 +421,27 @@ export function HomeScreen() {
|
|||||||
}
|
}
|
||||||
}, [selectionMode, toggleSelection]);
|
}, [selectionMode, toggleSelection]);
|
||||||
|
|
||||||
|
const renderSection = useCallback(({ item: groupKey }: { item: string }) => {
|
||||||
|
const groupFiles = groups[groupKey];
|
||||||
|
if (!groupFiles) return null;
|
||||||
|
const label = groupByTags ? groupKey : formatDateLabel(groupKey);
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
{groupByTags && <MaterialIcons name="label" size={16} color="#1976D2" style={styles.sectionIcon} />}
|
||||||
|
<Text style={styles.sectionTitle}>{label}</Text>
|
||||||
|
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
|
||||||
|
</View>
|
||||||
|
<FileGroup
|
||||||
|
groupFiles={groupFiles}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onItemPress={handleItemPress}
|
||||||
|
onItemLongPress={handleItemLongPress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [groups, groupByTags, selectedIds, handleItemPress, handleItemLongPress]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
@@ -453,32 +501,39 @@ export function HomeScreen() {
|
|||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isFetching && page === 1}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor="#1976D2"
|
||||||
|
colors={['#1976D2']}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={
|
||||||
|
hasMore ? (
|
||||||
|
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore} disabled={isFetching}>
|
||||||
|
{isFetching ? (
|
||||||
|
<ActivityIndicator size="small" color="#1976D2" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.loadMoreText}>
|
||||||
|
Charger plus ({loadedFiles}/{totalFiles})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : loadedFiles > 0 ? (
|
||||||
|
<Text style={styles.loadedAllText}>{loadedFiles} fichier{loadedFiles > 1 ? 's' : ''}</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<Text style={styles.emptyText}>
|
<Text style={styles.emptyText}>
|
||||||
{searchQuery ? 'Aucun résultat' : 'Aucun fichier'}
|
{debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
renderItem={({ item: groupKey }) => {
|
renderItem={renderSection}
|
||||||
const groupFiles = groups[groupKey];
|
|
||||||
const label = groupByTags ? groupKey : formatDateLabel(groupKey);
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
{groupByTags && <MaterialIcons name="label" size={16} color="#1976D2" style={styles.sectionIcon} />}
|
|
||||||
<Text style={styles.sectionTitle}>{label}</Text>
|
|
||||||
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
|
|
||||||
</View>
|
|
||||||
<FileGroup
|
|
||||||
groupFiles={groupFiles}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onItemPress={handleItemPress}
|
|
||||||
onItemLongPress={handleItemLongPress}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
@@ -650,6 +705,21 @@ export function HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
loadMoreBtn: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
loadMoreText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#1976D2',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
loadedAllText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#999',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { MaterialIcons } from '@expo/vector-icons';
|
|||||||
import { fileStore, FileRecord } from '../services/fileStore';
|
import { fileStore, FileRecord } from '../services/fileStore';
|
||||||
import { safDirectory } from '../services/safDirectory';
|
import { safDirectory } from '../services/safDirectory';
|
||||||
import { useSyncQueue } from '../hooks/useSyncQueue';
|
import { useSyncQueue } from '../hooks/useSyncQueue';
|
||||||
|
import { useAutoSync } from '../hooks/useAutoSync';
|
||||||
|
import { useSyncPush } from '../hooks/useSyncPush';
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return '';
|
if (bytes === 0) return '';
|
||||||
@@ -23,15 +25,28 @@ function formatSize(bytes: number): string {
|
|||||||
export function SyncDetailScreen() {
|
export function SyncDetailScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { pendingCount, isSyncing, refresh } = useSyncQueue();
|
const { pendingCount, isSyncing, refresh } = useSyncQueue();
|
||||||
|
const { triggerSync } = useAutoSync();
|
||||||
|
const { push } = useSyncPush();
|
||||||
|
|
||||||
const pendingFiles = useMemo(() => {
|
const pendingFiles = useMemo(() => {
|
||||||
return fileStore.getPendingSync();
|
return fileStore.getPendingSync();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSyncAll = useCallback(() => {
|
const handleSyncAll = useCallback(async () => {
|
||||||
// TODO: lancer l'upload batch de tous les fichiers pending
|
await triggerSync();
|
||||||
console.log(`[SyncDetail] sync demandé pour ${pendingFiles.length} fichiers`);
|
|
||||||
}, [pendingFiles.length]);
|
try {
|
||||||
|
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
|
||||||
|
const serverId = await getStoredDeviceServerId();
|
||||||
|
if (serverId) {
|
||||||
|
await push(serverId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// push notification is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}, [triggerSync, push, refresh]);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }: { item: FileRecord }) => (
|
const renderItem = useCallback(({ item }: { item: FileRecord }) => (
|
||||||
<View style={styles.fileRow}>
|
<View style={styles.fileRow}>
|
||||||
|
|||||||
+61
-3
@@ -1,10 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { useUpload, UploadFile } from '../hooks/useUpload';
|
import { useUpload, UploadFile } from '../hooks/useUpload';
|
||||||
|
import { usePollOcr } from '../hooks/usePollOcr';
|
||||||
import { UploadProgress } from '../components/UploadProgress';
|
import { UploadProgress } from '../components/UploadProgress';
|
||||||
import { UploadError } from '../types';
|
import { UploadError, HttpError } from '../types';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
import { ENDPOINTS } from '../constants/api';
|
||||||
|
|
||||||
function getUploadErrorMessage(err: UploadError): string {
|
function getUploadErrorMessage(err: UploadError): string {
|
||||||
switch (err.status) {
|
switch (err.status) {
|
||||||
@@ -29,6 +32,40 @@ export function UploadScreen() {
|
|||||||
const [uploadedCount, setUploadedCount] = useState(0);
|
const [uploadedCount, setUploadedCount] = useState(0);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const upload = useUpload();
|
const upload = useUpload();
|
||||||
|
const { pollOcr } = usePollOcr();
|
||||||
|
|
||||||
|
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[]) => {
|
const doUpload = async (files: UploadFile[]) => {
|
||||||
setUploadStatus('uploading');
|
setUploadStatus('uploading');
|
||||||
@@ -37,9 +74,30 @@ export function UploadScreen() {
|
|||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await upload.mutateAsync(files);
|
const deduped = await checkDupBeforeUpload(files);
|
||||||
|
if (deduped.length === 0) {
|
||||||
|
setUploadStatus('success');
|
||||||
|
setUploadedCount(files.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalCount(deduped.length);
|
||||||
|
const response = await upload.mutateAsync(deduped);
|
||||||
setUploadedCount(response.uploaded.length);
|
setUploadedCount(response.uploaded.length);
|
||||||
|
|
||||||
|
if (response.uploaded.length > 0) {
|
||||||
|
setUploadStatus('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) {
|
||||||
|
setUploadStatus('success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.errors.length > 0) {
|
if (response.errors.length > 0) {
|
||||||
setUploadStatus('error');
|
setUploadStatus('error');
|
||||||
const messages = response.errors.map((e) => getUploadErrorMessage(e));
|
const messages = response.errors.map((e) => getUploadErrorMessage(e));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
+15
-14
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { ENDPOINTS } from '../constants/api';
|
import { ENDPOINTS } from '../constants/api';
|
||||||
import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types';
|
import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types';
|
||||||
@@ -27,7 +27,7 @@ function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): Unif
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getPaginated>): {
|
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles>): {
|
||||||
data: UnifiedFileItem[];
|
data: UnifiedFileItem[];
|
||||||
meta: { page: number; total: number };
|
meta: { page: number; total: number };
|
||||||
} {
|
} {
|
||||||
@@ -37,7 +37,7 @@ function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getPaginated
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 50) {
|
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
|
||||||
const queryKey = parentId
|
const queryKey = parentId
|
||||||
? ['resources', parentId]
|
? ['resources', parentId]
|
||||||
: ['resources', 'root', page, limit];
|
: ['resources', 'root', page, limit];
|
||||||
@@ -93,19 +93,20 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const cached = fileStore.getPaginated(page, limit);
|
const cached = fileStore.getRootFiles();
|
||||||
return recordsToUnifiedItems(cached);
|
return {
|
||||||
|
data: cached.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||||
|
meta: { page, total: backendRes.meta?.total ?? cached.total },
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
initialData: () => {
|
initialData: () => {
|
||||||
let records;
|
const cached = fileStore.getRootFiles();
|
||||||
if (parentId) {
|
if (cached.files.length === 0) return undefined;
|
||||||
const children = fileStore.getChildrenByParent(parentId);
|
return {
|
||||||
records = { files: children, total: children.length };
|
data: cached.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||||
} else {
|
meta: { page: 0, total: cached.total },
|
||||||
records = fileStore.getPaginated(page, limit);
|
};
|
||||||
}
|
|
||||||
if (records.files.length === 0) return undefined;
|
|
||||||
return recordsToUnifiedItems(records);
|
|
||||||
},
|
},
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
import { ENDPOINTS } from '../constants/api';
|
||||||
|
import { fileStore } from '../services/fileStore';
|
||||||
|
|
||||||
|
const MAX_POLL_MS = 120_000;
|
||||||
|
const POLL_INTERVAL = 3_000;
|
||||||
|
|
||||||
|
export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' };
|
||||||
|
|
||||||
|
export function usePollOcr() {
|
||||||
|
const running = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const pollOcr = useCallback(async (resourceId: string): Promise<OcrPollResult> => {
|
||||||
|
if (running.current.has(resourceId)) return { resourceId, status: 'failed' };
|
||||||
|
running.current.add(resourceId);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
while (Date.now() - start < MAX_POLL_MS) {
|
||||||
|
try {
|
||||||
|
const detail = await apiClient.get<{ data: { ocrText?: string } }>(
|
||||||
|
`${ENDPOINTS.RESOURCES}/${resourceId}`,
|
||||||
|
);
|
||||||
|
const ocrText = detail.data?.ocrText;
|
||||||
|
if (ocrText && ocrText.length > 0) {
|
||||||
|
fileStore.updatePartial(resourceId, { ocrText });
|
||||||
|
return { resourceId, status: 'completed' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { resourceId, status: 'failed' };
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
||||||
|
}
|
||||||
|
return { resourceId, status: 'timeout' };
|
||||||
|
} finally {
|
||||||
|
running.current.delete(resourceId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pollOcr };
|
||||||
|
}
|
||||||
@@ -304,6 +304,23 @@ export const fileStore = {
|
|||||||
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
return rows.map((r) => rowToRecord(r, getTagsForFile(r.id)));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRootFiles(): { files: FileRecord[]; total: number } {
|
||||||
|
const d = getDb();
|
||||||
|
const countRow = d.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(files)
|
||||||
|
.where(and(isNull(files.parentResourceId), eq(files.isFolder, 0)))
|
||||||
|
.get();
|
||||||
|
const total = countRow?.count ?? 0;
|
||||||
|
const rows = d.select().from(files)
|
||||||
|
.where(and(isNull(files.parentResourceId), eq(files.isFolder, 0)))
|
||||||
|
.orderBy(desc(files.createdAt))
|
||||||
|
.all() as FileRow[];
|
||||||
|
return {
|
||||||
|
files: rows.map((r) => rowToRecord(r, getTagsForFile(r.id))),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } {
|
getPaginated(page: number, limit: number): { files: FileRecord[]; total: number } {
|
||||||
const d = getDb();
|
const d = getDb();
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -414,7 +431,7 @@ export const fileStore = {
|
|||||||
lastSyncedAt: now,
|
lastSyncedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bf.tags) setTagsForFile(bf.id, bf.tags);
|
if (bf.tags && bf.tags.length > 0) setTagsForFile(bf.id, bf.tags);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user