sse implémentaton
This commit is contained in:
@@ -54,6 +54,18 @@ function eventToDeviceFile(event: FileDetectedEvent): DeviceFile {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -78,11 +90,62 @@ async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function scanMediaAlbum(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||
try {
|
||||
if (!folder.albumId) return [];
|
||||
const result = await MediaLibrary.getAssetsAsync({
|
||||
album: folder.albumId,
|
||||
first: 500,
|
||||
sortBy: 'creationTime',
|
||||
});
|
||||
return result.assets.map((asset) => ({
|
||||
id: `media_${folder.id}_${asset.id}`,
|
||||
uri: asset.uri,
|
||||
name: asset.filename,
|
||||
mimeType: asset.mediaType === 'video' ? 'video/mp4' : 'image/jpeg',
|
||||
size: 0,
|
||||
createdAt: asset.creationTime
|
||||
? new Date(asset.creationTime).toISOString()
|
||||
: new Date().toISOString(),
|
||||
folderId: folder.id,
|
||||
}));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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 [hasPermission, setHasPermission] = useState(false);
|
||||
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
|
||||
const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered());
|
||||
const { newFiles, clearNewFiles } = useFileWatcher();
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
@@ -125,11 +188,35 @@ export function useDeviceFiles() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const discoverMediaAlbums = useCallback(async (): Promise<number> => {
|
||||
try {
|
||||
const albums = await MediaLibrary.getAlbumsAsync();
|
||||
let added = 0;
|
||||
for (const album of albums) {
|
||||
if (album.title && album.assetCount > 0) {
|
||||
safDirectory.addMediaFolder(album.id, album.title, '');
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
setFolders(safDirectory.getAll());
|
||||
}
|
||||
return added;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scanVisibleFolders = useCallback(async () => {
|
||||
const visibleFolders = safDirectory.getVisibleFolders();
|
||||
if (visibleFolders.length === 0) return;
|
||||
|
||||
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
|
||||
const results = await Promise.all(
|
||||
visibleFolders.map((folder) => {
|
||||
if (folder.source === 'media-library') return scanMediaAlbum(folder);
|
||||
return scanSafFolder(folder);
|
||||
})
|
||||
);
|
||||
const safFiles = results.flat();
|
||||
|
||||
setFiles((prev) => {
|
||||
@@ -174,7 +261,7 @@ export function useDeviceFiles() {
|
||||
safDirectory.addFolder(dirUri, dirName);
|
||||
setFolders(safDirectory.getAll());
|
||||
|
||||
const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false });
|
||||
const folderFiles = await scanSafFolder({ id: 'new', uri: dirUri, name: dirName, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' });
|
||||
setFiles((prev) => {
|
||||
const existing = new Set(prev.map((f) => f.id));
|
||||
const merged = [...prev];
|
||||
@@ -189,6 +276,54 @@ export function useDeviceFiles() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 runInitialDiscovery = useCallback(async () => {
|
||||
if (safDirectory.getDiscovered()) return;
|
||||
|
||||
const granted = hasPermission || await requestPermission();
|
||||
if (granted) {
|
||||
const mediaCount = await discoverMediaAlbums();
|
||||
if (mediaCount > 0) {
|
||||
await scanVisibleFolders();
|
||||
}
|
||||
}
|
||||
|
||||
safDirectory.setDiscovered();
|
||||
setDiscovered(true);
|
||||
}, [hasPermission, requestPermission, discoverMediaAlbums, scanVisibleFolders]);
|
||||
|
||||
const refreshFolders = useCallback(() => {
|
||||
setFolders(safDirectory.getAll());
|
||||
}, []);
|
||||
@@ -206,10 +341,8 @@ export function useDeviceFiles() {
|
||||
useEffect(() => {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
// Persist to MMKV
|
||||
downloadRegistry.addBatch(newFiles);
|
||||
|
||||
// Add to in-memory state
|
||||
const newDeviceFiles = newFiles.map(eventToDeviceFile);
|
||||
setFiles((prev) => {
|
||||
const existing = new Set(prev.map((f) => f.id));
|
||||
@@ -232,8 +365,15 @@ export function useDeviceFiles() {
|
||||
});
|
||||
scanVisibleFolders();
|
||||
loadRegistryFiles();
|
||||
runInitialDiscovery();
|
||||
}, []);
|
||||
|
||||
return { files, isLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders };
|
||||
return {
|
||||
files, isLoading, hasPermission,
|
||||
requestPermission, rescan,
|
||||
pickDirectory, pickAndScanRecursive,
|
||||
folders, refreshFolders,
|
||||
discovered, discoverMediaAlbums,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fileStore } from '../services/fileStore';
|
||||
import { UnifiedFileItem } from '../types';
|
||||
|
||||
export function useLocalFiles() {
|
||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
|
||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, pickAndScanRecursive, folders, refreshFolders, discovered, discoverMediaAlbums } = useDeviceFiles();
|
||||
const lastDeviceCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,7 +76,10 @@ export function useLocalFiles() {
|
||||
requestPermission,
|
||||
rescan,
|
||||
pickDirectory,
|
||||
pickAndScanRecursive,
|
||||
folders,
|
||||
refreshFolders,
|
||||
discovered,
|
||||
discoverMediaAlbums,
|
||||
};
|
||||
}
|
||||
|
||||
+62
-28
@@ -1,41 +1,75 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } 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;
|
||||
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) => {
|
||||
const ocrText = detail.data?.ocrText;
|
||||
if (ocrText) {
|
||||
fileStore.updatePartial(resourceId, { ocrText });
|
||||
}
|
||||
entry.resolve({ resourceId, status: 'completed' });
|
||||
})
|
||||
.catch(() => {
|
||||
entry.resolve({ resourceId, status: 'failed' });
|
||||
})
|
||||
.finally(() => {
|
||||
delete globalOcrState[resourceId];
|
||||
globalSetState?.(Date.now());
|
||||
});
|
||||
}
|
||||
|
||||
export function SseOcrListener() {
|
||||
const { onOcrDone } = useOcrDone();
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onOcrDone(handleOcrDone);
|
||||
return unsub;
|
||||
}, [onOcrDone]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function usePollOcr() {
|
||||
const running = useRef<Set<string>>(new Set());
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
globalSetState = forceUpdate;
|
||||
return () => {
|
||||
globalSetState = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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 };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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, OcrJob, UploadError } from '../types';
|
||||
import { ApiError, UploadError } from '../types';
|
||||
|
||||
export type UploadFile = { uri: string; type: string; name: string };
|
||||
export type UploadResult = { name: string; id: string };
|
||||
@@ -76,15 +76,4 @@ export function useUpload() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOcrJobStatus(jobId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['ocr', jobId],
|
||||
queryFn: () => apiClient.get<OcrJob>(`${ENDPOINTS.OCR_JOBS}/${jobId}`),
|
||||
enabled: !!jobId,
|
||||
refetchInterval: (query: any) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'completed' || status === 'failed') return false;
|
||||
return 1000;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user