sse implémentaton
This commit is contained in:
+9
-4
@@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import { DeviceProvider, useDevice } from './contexts/DeviceContext';
|
import { DeviceProvider, useDevice } from './contexts/DeviceContext';
|
||||||
|
import { SseProvider } from './contexts/SseContext';
|
||||||
|
import { SseOcrListener } from './hooks/usePollOcr';
|
||||||
import { LoginScreen } from './app/login';
|
import { LoginScreen } from './app/login';
|
||||||
import { RegisterScreen } from './app/register';
|
import { RegisterScreen } from './app/register';
|
||||||
import { HomeScreen } from './app/index';
|
import { HomeScreen } from './app/index';
|
||||||
@@ -103,10 +105,13 @@ function AppNavigator() {
|
|||||||
function AppContent() {
|
function AppContent() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<DeviceProvider>
|
<SseProvider>
|
||||||
<AppNavigator />
|
<SseOcrListener />
|
||||||
<StatusBar style="auto" />
|
<DeviceProvider>
|
||||||
</DeviceProvider>
|
<AppNavigator />
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</DeviceProvider>
|
||||||
|
</SseProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,68 @@ class ApiClient {
|
|||||||
await tokenStorage.deleteUser();
|
await tokenStorage.deleteUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async subscribeEvents(
|
||||||
|
onEvent: (event: string, data: string) => void,
|
||||||
|
onError: (err: Error) => void,
|
||||||
|
): Promise<() => void> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
};
|
||||||
|
if (this.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}${ENDPOINTS.EVENTS}`, {
|
||||||
|
headers,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SSE connection failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('Streaming not supported');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let currentEvent = '';
|
||||||
|
|
||||||
|
while (!abortController.signal.aborted) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
currentEvent = line.slice(7);
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
if (currentEvent) {
|
||||||
|
onEvent(currentEvent, line.slice(6));
|
||||||
|
currentEvent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
onError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
async get<T>(endpoint: string): Promise<T> {
|
async get<T>(endpoint: string): Promise<T> {
|
||||||
return this.request<T>(endpoint);
|
return this.request<T>(endpoint);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -94,7 +94,7 @@ export function HomeScreen() {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE);
|
const { data, isLoading, error, isFetching, refetch } = useFiles(null, page, PAGE_SIZE);
|
||||||
const { hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
const { hasPermission, requestPermission, pickDirectory, pickAndScanRecursive, folders, refreshFolders, discovered } = useLocalFiles();
|
||||||
const freeLocalSpace = useFreeLocalSpace();
|
const freeLocalSpace = useFreeLocalSpace();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -336,6 +336,11 @@ export function HomeScreen() {
|
|||||||
await pickDirectory();
|
await pickDirectory();
|
||||||
}, [pickDirectory]);
|
}, [pickDirectory]);
|
||||||
|
|
||||||
|
const handleAddFolderRecursive = useCallback(async () => {
|
||||||
|
setSettingsModalVisible(false);
|
||||||
|
await pickAndScanRecursive();
|
||||||
|
}, [pickAndScanRecursive]);
|
||||||
|
|
||||||
const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => {
|
const handleUpdateSyncMode = useCallback((folderId: string, mode: SyncMode) => {
|
||||||
safDirectory.updateSyncMode(folderId, mode);
|
safDirectory.updateSyncMode(folderId, mode);
|
||||||
refreshFolders();
|
refreshFolders();
|
||||||
@@ -438,12 +443,29 @@ export function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{files.length === 0 && !searchQuery && (
|
{files.length === 0 && !searchQuery && (
|
||||||
<TouchableOpacity style={styles.folderScanBanner} onPress={pickDirectory}>
|
<View style={styles.folderScanBanner}>
|
||||||
<MaterialIcons name="folder-open" size={24} color="#F57C00" />
|
{folders.length > 0 ? (
|
||||||
<Text style={styles.folderScanText}>
|
<>
|
||||||
{folders.length > 0 ? 'Aucun fichier trouvé — re-sélectionner' : 'Scanner un dossier de votre appareil'}
|
<MaterialIcons name="folder" size={24} color="#F57C00" />
|
||||||
</Text>
|
<Text style={styles.folderScanText}>
|
||||||
</TouchableOpacity>
|
{folders.length} dossier{folders.length > 1 ? 's' : ''} scanné{folders.length > 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionBtn} onPress={pickAndScanRecursive}>
|
||||||
|
<Text style={styles.permissionBtnText}>Tout scanner</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MaterialIcons name="folder-open" size={24} color="#F57C00" />
|
||||||
|
<Text style={styles.folderScanText}>
|
||||||
|
Scanner un dossier de votre appareil
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionBtn} onPress={pickAndScanRecursive}>
|
||||||
|
<Text style={styles.permissionBtnText}>Choisir</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
<GestureDetector gesture={pinchGesture}>
|
<GestureDetector gesture={pinchGesture}>
|
||||||
<View style={styles.listWrapper}>
|
<View style={styles.listWrapper}>
|
||||||
@@ -647,6 +669,7 @@ export function HomeScreen() {
|
|||||||
onToggleVisibility={handleToggleFolderVisibility}
|
onToggleVisibility={handleToggleFolderVisibility}
|
||||||
onRemoveFolder={handleRemoveFolder}
|
onRemoveFolder={handleRemoveFolder}
|
||||||
onAddFolder={handleAddFolder}
|
onAddFolder={handleAddFolder}
|
||||||
|
onAddFolderRecursive={handleAddFolderRecursive}
|
||||||
onUpdateSyncMode={handleUpdateSyncMode}
|
onUpdateSyncMode={handleUpdateSyncMode}
|
||||||
onUpdateSyncCellular={handleUpdateSyncCellular}
|
onUpdateSyncCellular={handleUpdateSyncCellular}
|
||||||
globalSyncMode={globalSyncMode}
|
globalSyncMode={globalSyncMode}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { StoredFolder, SyncMode, SyncGlobalMode } from '../services/safDirectory';
|
import { StoredFolder, SyncMode, SyncGlobalMode, type FolderSource } from '../services/safDirectory';
|
||||||
|
|
||||||
type SettingsView = 'menu' | 'folders' | 'sync';
|
type SettingsView = 'menu' | 'folders' | 'sync';
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ interface SettingsModalProps {
|
|||||||
onToggleVisibility: (folderId: string) => void;
|
onToggleVisibility: (folderId: string) => void;
|
||||||
onRemoveFolder: (folderId: string) => void;
|
onRemoveFolder: (folderId: string) => void;
|
||||||
onAddFolder: () => void;
|
onAddFolder: () => void;
|
||||||
|
onAddFolderRecursive?: () => void;
|
||||||
onUpdateSyncMode: (folderId: string, mode: SyncMode) => void;
|
onUpdateSyncMode: (folderId: string, mode: SyncMode) => void;
|
||||||
onUpdateSyncCellular: (folderId: string, enabled: boolean) => void;
|
onUpdateSyncCellular: (folderId: string, enabled: boolean) => void;
|
||||||
globalSyncMode: SyncGlobalMode;
|
globalSyncMode: SyncGlobalMode;
|
||||||
@@ -47,6 +48,7 @@ export function SettingsModal({
|
|||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onRemoveFolder,
|
onRemoveFolder,
|
||||||
onAddFolder,
|
onAddFolder,
|
||||||
|
onAddFolderRecursive,
|
||||||
onUpdateSyncMode,
|
onUpdateSyncMode,
|
||||||
onUpdateSyncCellular,
|
onUpdateSyncCellular,
|
||||||
globalSyncMode,
|
globalSyncMode,
|
||||||
@@ -108,6 +110,7 @@ export function SettingsModal({
|
|||||||
onToggleVisibility={onToggleVisibility}
|
onToggleVisibility={onToggleVisibility}
|
||||||
onRemoveFolder={handleRemoveFolder}
|
onRemoveFolder={handleRemoveFolder}
|
||||||
onAddFolder={onAddFolder}
|
onAddFolder={onAddFolder}
|
||||||
|
onAddFolderRecursive={onAddFolderRecursive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -163,18 +166,31 @@ function MenuView({ onSelect, onClose }: { onSelect: (v: SettingsView) => void;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderIcon(folder: StoredFolder): { name: keyof typeof MaterialIcons.glyphMap; color: string } {
|
||||||
|
switch (folder.source) {
|
||||||
|
case 'media-library':
|
||||||
|
return { name: 'photo-library', color: '#43A047' };
|
||||||
|
case 'recursive':
|
||||||
|
return { name: 'subdirectory-arrow-right', color: '#8E24AA' };
|
||||||
|
default:
|
||||||
|
return { name: 'folder', color: '#F57C00' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function FoldersView({
|
function FoldersView({
|
||||||
folders,
|
folders,
|
||||||
onBack,
|
onBack,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
onRemoveFolder,
|
onRemoveFolder,
|
||||||
onAddFolder,
|
onAddFolder,
|
||||||
|
onAddFolderRecursive,
|
||||||
}: {
|
}: {
|
||||||
folders: StoredFolder[];
|
folders: StoredFolder[];
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onToggleVisibility: (id: string) => void;
|
onToggleVisibility: (id: string) => void;
|
||||||
onRemoveFolder: (folder: StoredFolder) => void;
|
onRemoveFolder: (folder: StoredFolder) => void;
|
||||||
onAddFolder: () => void;
|
onAddFolder: () => void;
|
||||||
|
onAddFolderRecursive?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.viewContainer} showsVerticalScrollIndicator={false}>
|
||||||
@@ -188,36 +204,46 @@ function FoldersView({
|
|||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
|
<Text style={styles.emptyText}>Aucun dossier configuré</Text>
|
||||||
) : (
|
) : (
|
||||||
folders.map((folder) => (
|
folders.map((folder) => {
|
||||||
<View key={folder.id} style={styles.folderRow}>
|
const icon = folderIcon(folder);
|
||||||
<MaterialIcons name="folder" size={20} color="#F57C00" />
|
return (
|
||||||
<Text style={styles.folderName} numberOfLines={1}>
|
<View key={folder.id} style={styles.folderRow}>
|
||||||
{folder.name}
|
<MaterialIcons name={icon.name} size={20} color={icon.color} />
|
||||||
</Text>
|
<Text style={styles.folderName} numberOfLines={1}>
|
||||||
<TouchableOpacity
|
{folder.name}
|
||||||
onPress={() => onToggleVisibility(folder.id)}
|
</Text>
|
||||||
style={styles.actionBtn}
|
<TouchableOpacity
|
||||||
>
|
onPress={() => onToggleVisibility(folder.id)}
|
||||||
<MaterialIcons
|
style={styles.actionBtn}
|
||||||
name={folder.visible ? 'visibility' : 'visibility-off'}
|
>
|
||||||
size={20}
|
<MaterialIcons
|
||||||
color={folder.visible ? '#1976D2' : '#999'}
|
name={folder.visible ? 'visibility' : 'visibility-off'}
|
||||||
/>
|
size={20}
|
||||||
</TouchableOpacity>
|
color={folder.visible ? '#1976D2' : '#999'}
|
||||||
<TouchableOpacity
|
/>
|
||||||
onPress={() => onRemoveFolder(folder)}
|
</TouchableOpacity>
|
||||||
style={styles.actionBtn}
|
<TouchableOpacity
|
||||||
>
|
onPress={() => onRemoveFolder(folder)}
|
||||||
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
|
style={styles.actionBtn}
|
||||||
</TouchableOpacity>
|
>
|
||||||
</View>
|
<MaterialIcons name="delete-outline" size={20} color="#E53935" />
|
||||||
))
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TouchableOpacity style={styles.addBtn} onPress={onAddFolder}>
|
<TouchableOpacity style={styles.addBtn} onPress={onAddFolder}>
|
||||||
<MaterialIcons name="add" size={20} color="#fff" />
|
<MaterialIcons name="add" size={20} color="#fff" />
|
||||||
<Text style={styles.addBtnText}>Ajouter un dossier</Text>
|
<Text style={styles.addBtnText}>Ajouter un dossier</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{onAddFolderRecursive && (
|
||||||
|
<TouchableOpacity style={styles.scanRootBtn} onPress={onAddFolderRecursive}>
|
||||||
|
<MaterialIcons name="linear-scale" size={20} color="#fff" />
|
||||||
|
<Text style={styles.addBtnText}>Ajouter (scan récursif)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -504,6 +530,16 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
|
scanRootBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#8E24AA',
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginTop: 10,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
addBtnText: {
|
addBtnText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Modal, Alert } from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
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 { usePollOcr, SseOcrListener } from '../hooks/usePollOcr';
|
||||||
import { UploadProgress } from './UploadProgress';
|
import { UploadProgress } from './UploadProgress';
|
||||||
import { UploadError } from '../types';
|
import { UploadError } from '../types';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const ENDPOINTS = {
|
|||||||
VARIANT: '/variants',
|
VARIANT: '/variants',
|
||||||
DEDUP_CHECK: '/resources/dedup-check',
|
DEDUP_CHECK: '/resources/dedup-check',
|
||||||
OCR_JOBS: '/ocr/jobs',
|
OCR_JOBS: '/ocr/jobs',
|
||||||
|
EVENTS: '/events',
|
||||||
HEALTH: '/health',
|
HEALTH: '/health',
|
||||||
AUTH_LOGIN: '/auth/login',
|
AUTH_LOGIN: '/auth/login',
|
||||||
AUTH_REGISTER: '/auth/register',
|
AUTH_REGISTER: '/auth/register',
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
type OcrDoneCallback = (resourceId: string) => void;
|
||||||
|
|
||||||
|
interface SseContextValue {
|
||||||
|
onOcrDone: (cb: OcrDoneCallback) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SseContext = createContext<SseContextValue>({
|
||||||
|
onOcrDone: () => () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SseProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const listenersRef = useRef<Set<OcrDoneCallback>>(new Set());
|
||||||
|
const cancelRef = useRef<() => void>(() => {});
|
||||||
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
const cancel = await apiClient.subscribeEvents(
|
||||||
|
(event, data) => {
|
||||||
|
if (event === 'ocr_done') {
|
||||||
|
listenersRef.current.forEach((cb) => cb(data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (cancelled) {
|
||||||
|
cancel();
|
||||||
|
} else {
|
||||||
|
cancelRef.current = cancel;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = connect();
|
||||||
|
return () => {
|
||||||
|
cleanup?.();
|
||||||
|
cancelRef.current();
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
const onOcrDone = useCallback((cb: OcrDoneCallback) => {
|
||||||
|
listenersRef.current.add(cb);
|
||||||
|
return () => {
|
||||||
|
listenersRef.current.delete(cb);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SseContext.Provider value={{ onOcrDone }}>
|
||||||
|
{children}
|
||||||
|
</SseContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOcrDone(onDone?: OcrDoneCallback) {
|
||||||
|
const ctx = useContext(SseContext);
|
||||||
|
useEffect(() => {
|
||||||
|
if (onDone) return ctx.onOcrDone(onDone);
|
||||||
|
}, [onDone, ctx]);
|
||||||
|
return { onOcrDone: ctx.onOcrDone };
|
||||||
|
}
|
||||||
@@ -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[]> {
|
async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||||
try {
|
try {
|
||||||
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri);
|
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() {
|
export function useDeviceFiles() {
|
||||||
const [files, setFiles] = useState<DeviceFile[]>([]);
|
const [files, setFiles] = useState<DeviceFile[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasPermission, setHasPermission] = useState(false);
|
const [hasPermission, setHasPermission] = useState(false);
|
||||||
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
|
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
|
||||||
|
const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered());
|
||||||
const { newFiles, clearNewFiles } = useFileWatcher();
|
const { newFiles, clearNewFiles } = useFileWatcher();
|
||||||
|
|
||||||
const requestPermission = useCallback(async () => {
|
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 scanVisibleFolders = useCallback(async () => {
|
||||||
const visibleFolders = safDirectory.getVisibleFolders();
|
const visibleFolders = safDirectory.getVisibleFolders();
|
||||||
if (visibleFolders.length === 0) return;
|
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();
|
const safFiles = results.flat();
|
||||||
|
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
@@ -174,7 +261,7 @@ export function useDeviceFiles() {
|
|||||||
safDirectory.addFolder(dirUri, dirName);
|
safDirectory.addFolder(dirUri, dirName);
|
||||||
setFolders(safDirectory.getAll());
|
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) => {
|
setFiles((prev) => {
|
||||||
const existing = new Set(prev.map((f) => f.id));
|
const existing = new Set(prev.map((f) => f.id));
|
||||||
const merged = [...prev];
|
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(() => {
|
const refreshFolders = useCallback(() => {
|
||||||
setFolders(safDirectory.getAll());
|
setFolders(safDirectory.getAll());
|
||||||
}, []);
|
}, []);
|
||||||
@@ -206,10 +341,8 @@ export function useDeviceFiles() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (newFiles.length === 0) return;
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
// Persist to MMKV
|
|
||||||
downloadRegistry.addBatch(newFiles);
|
downloadRegistry.addBatch(newFiles);
|
||||||
|
|
||||||
// Add to in-memory state
|
|
||||||
const newDeviceFiles = newFiles.map(eventToDeviceFile);
|
const newDeviceFiles = newFiles.map(eventToDeviceFile);
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const existing = new Set(prev.map((f) => f.id));
|
const existing = new Set(prev.map((f) => f.id));
|
||||||
@@ -232,8 +365,15 @@ export function useDeviceFiles() {
|
|||||||
});
|
});
|
||||||
scanVisibleFolders();
|
scanVisibleFolders();
|
||||||
loadRegistryFiles();
|
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';
|
import { UnifiedFileItem } from '../types';
|
||||||
|
|
||||||
export function useLocalFiles() {
|
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);
|
const lastDeviceCount = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,7 +76,10 @@ export function useLocalFiles() {
|
|||||||
requestPermission,
|
requestPermission,
|
||||||
rescan,
|
rescan,
|
||||||
pickDirectory,
|
pickDirectory,
|
||||||
|
pickAndScanRecursive,
|
||||||
folders,
|
folders,
|
||||||
refreshFolders,
|
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 { apiClient } from '../api/client';
|
||||||
import { ENDPOINTS } from '../constants/api';
|
import { ENDPOINTS } from '../constants/api';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
|
import { useOcrDone } from '../contexts/SseContext';
|
||||||
const MAX_POLL_MS = 120_000;
|
|
||||||
const POLL_INTERVAL = 3_000;
|
|
||||||
|
|
||||||
export type OcrPollResult = { resourceId: string; status: 'completed' | 'failed' | 'timeout' };
|
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() {
|
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> => {
|
const pollOcr = useCallback(async (resourceId: string): Promise<OcrPollResult> => {
|
||||||
if (running.current.has(resourceId)) return { resourceId, status: 'failed' };
|
const existing = globalOcrState[resourceId];
|
||||||
running.current.add(resourceId);
|
if (existing) {
|
||||||
|
return new Promise((resolve) => existing.resolve = resolve);
|
||||||
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 new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
delete globalOcrState[resourceId];
|
||||||
|
resolve({ resourceId, status: 'timeout' });
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
globalOcrState[resourceId] = { resolve, timeout };
|
||||||
|
globalSetState?.(Date.now());
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { pollOcr };
|
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 { File, UploadType } from 'expo-file-system';
|
||||||
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, OcrJob, UploadError } from '../types';
|
import { ApiError, UploadError } from '../types';
|
||||||
|
|
||||||
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 };
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createMMKV } from 'react-native-mmkv';
|
|||||||
|
|
||||||
export type SyncMode = 'none' | 'manual' | 'auto';
|
export type SyncMode = 'none' | 'manual' | 'auto';
|
||||||
export type SyncGlobalMode = 'off' | 'auto' | 'manual';
|
export type SyncGlobalMode = 'off' | 'auto' | 'manual';
|
||||||
|
export type FolderSource = 'saf' | 'media-library' | 'recursive';
|
||||||
|
|
||||||
export type StoredFolder = {
|
export type StoredFolder = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,6 +11,9 @@ export type StoredFolder = {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
syncMode: SyncMode;
|
syncMode: SyncMode;
|
||||||
syncCellular: boolean;
|
syncCellular: boolean;
|
||||||
|
source: FolderSource;
|
||||||
|
albumId?: string;
|
||||||
|
parentUri?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const storage = createMMKV({ id: 'vaultdrop-saf' });
|
const storage = createMMKV({ id: 'vaultdrop-saf' });
|
||||||
@@ -17,6 +21,7 @@ const storage = createMMKV({ id: 'vaultdrop-saf' });
|
|||||||
const FOLDERS_KEY = 'saf_folders';
|
const FOLDERS_KEY = 'saf_folders';
|
||||||
const SYNC_GLOBAL_MODE_KEY = 'sync_global_mode';
|
const SYNC_GLOBAL_MODE_KEY = 'sync_global_mode';
|
||||||
const SYNC_GLOBAL_CELLULAR_KEY = 'sync_global_cellular';
|
const SYNC_GLOBAL_CELLULAR_KEY = 'sync_global_cellular';
|
||||||
|
const DISCOVERED_KEY = 'folders_discovered';
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -36,6 +41,10 @@ function getAllRaw(): StoredFolder[] {
|
|||||||
(f as any).syncCellular = false;
|
(f as any).syncCellular = false;
|
||||||
migrated = true;
|
migrated = true;
|
||||||
}
|
}
|
||||||
|
if (f.source === undefined) {
|
||||||
|
(f as any).source = 'saf';
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (migrated) saveAll(parsed);
|
if (migrated) saveAll(parsed);
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -59,12 +68,67 @@ export const safDirectory = {
|
|||||||
if (folders.some((f) => f.uri === uri)) {
|
if (folders.some((f) => f.uri === uri)) {
|
||||||
return folders.find((f) => f.uri === uri)!;
|
return folders.find((f) => f.uri === uri)!;
|
||||||
}
|
}
|
||||||
const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false };
|
const folder: StoredFolder = { id: generateId(), uri, name, visible: true, syncMode: 'none', syncCellular: false, source: 'saf' };
|
||||||
folders.push(folder);
|
folders.push(folder);
|
||||||
saveAll(folders);
|
saveAll(folders);
|
||||||
return folder;
|
return folder;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addMediaFolder(albumId: string, name: string, uri: string): StoredFolder {
|
||||||
|
const folders = getAllRaw();
|
||||||
|
const key = `media://${albumId}`;
|
||||||
|
if (folders.some((f) => f.uri === key)) {
|
||||||
|
return folders.find((f) => f.uri === key)!;
|
||||||
|
}
|
||||||
|
const folder: StoredFolder = {
|
||||||
|
id: generateId(),
|
||||||
|
uri: key,
|
||||||
|
name,
|
||||||
|
visible: true,
|
||||||
|
syncMode: 'none',
|
||||||
|
syncCellular: false,
|
||||||
|
source: 'media-library',
|
||||||
|
albumId,
|
||||||
|
};
|
||||||
|
folders.push(folder);
|
||||||
|
saveAll(folders);
|
||||||
|
return folder;
|
||||||
|
},
|
||||||
|
|
||||||
|
addBatchFolders(folders: Array<{ uri: string; name: string; source?: FolderSource; parentUri?: string }>): StoredFolder[] {
|
||||||
|
const current = getAllRaw();
|
||||||
|
const added: StoredFolder[] = [];
|
||||||
|
for (const f of folders) {
|
||||||
|
if (current.some((existing) => existing.uri === f.uri)) continue;
|
||||||
|
const folder: StoredFolder = {
|
||||||
|
id: generateId(),
|
||||||
|
uri: f.uri,
|
||||||
|
name: f.name,
|
||||||
|
visible: true,
|
||||||
|
syncMode: 'none',
|
||||||
|
syncCellular: false,
|
||||||
|
source: f.source ?? 'recursive',
|
||||||
|
parentUri: f.parentUri,
|
||||||
|
};
|
||||||
|
current.push(folder);
|
||||||
|
added.push(folder);
|
||||||
|
}
|
||||||
|
saveAll(current);
|
||||||
|
return added;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDiscovered(): boolean {
|
||||||
|
return storage.getString(DISCOVERED_KEY) === 'true';
|
||||||
|
},
|
||||||
|
|
||||||
|
setDiscovered() {
|
||||||
|
storage.set(DISCOVERED_KEY, 'true');
|
||||||
|
},
|
||||||
|
|
||||||
|
resetDiscovered() {
|
||||||
|
storage.remove(DISCOVERED_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
removeFolder(id: string) {
|
removeFolder(id: string) {
|
||||||
const folders = getAllRaw().filter((f) => f.id !== id);
|
const folders = getAllRaw().filter((f) => f.id !== id);
|
||||||
saveAll(folders);
|
saveAll(folders);
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export interface RefreshResponse {
|
|||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SseEvent = {
|
||||||
|
event: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
|
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
|
|||||||
Reference in New Issue
Block a user