better iterface

This commit is contained in:
m
2026-09-10 06:46:22 +02:00
parent 27d4eb6a65
commit 8ff2339c8a
14 changed files with 498 additions and 43 deletions
+4 -1
View File
@@ -48,8 +48,11 @@ cd mobile && npm run test:db
- `localStorage.ts` — thin re-export of `services/db` (legacy alias) - `localStorage.ts` — thin re-export of `services/db` (legacy alias)
- `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation) - `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation)
- `context/AuthContext.tsx` — session context: exposes `deviceUserId` (bootstrapped from `getDeviceUserId()`) and starts the background `syncDevice` loop - `context/AuthContext.tsx` — session context: exposes `deviceUserId` (bootstrapped from `getDeviceUserId()`) and starts the background `syncDevice` loop
- `app/` — expo-router screens: `index.tsx` (dossiers racines + ajout SAF), `folder/[id].tsx` (sous-dossiers + fichiers)
- `api/` — REST client (`client.ts` fetch wrapper + `types.ts` = contrat d'API : enveloppe `{ data, meta }`, erreurs `{ error: { code, message } }`)
- `hooks/` — TanStack Query hooks: `useFiles`, `useSearch`, `useUpload` (+ OCR jobs)
- No business logic on the client — heavy processing stays server-side - No business logic on the client — heavy processing stays server-side
- API base URL configured via `EXPO_PUBLIC_API_BASE_URL` env var (client + hooks not yet built) - API base URL via `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`)
## Data Conventions ## Data Conventions
+4
View File
@@ -21,3 +21,7 @@ Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db
- Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`markPendingOperation`. - Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`markPendingOperation`.
- SAF walk (`features/syncDevice.ts`, `syncDevice()`/`syncRoot()`): **two passes** (all folders sorted by uri depth, then all files) inside a **single transaction** (`withTransaction`), receives sync: an interruption rolls back entirely. Reconciles by physical `uri`; rows under the root with a `uri` no longer seen are **marked `exists = 0`** (never deleted). Root folders are the rows with `parent_resource_id IS NULL` + non-null `uri`. - SAF walk (`features/syncDevice.ts`, `syncDevice()`/`syncRoot()`): **two passes** (all folders sorted by uri depth, then all files) inside a **single transaction** (`withTransaction`), receives sync: an interruption rolls back entirely. Reconciles by physical `uri`; rows under the root with a `uri` no longer seen are **marked `exists = 0`** (never deleted). Root folders are the rows with `parent_resource_id IS NULL` + non-null `uri`.
- Heavy processing stays server-side; SQLite only persists local metadata/state. - Heavy processing stays server-side; SQLite only persists local metadata/state.
# REST API client
`api/client.ts` + `api/types.ts` = the client-side **API contract** (server must implement it; backend Go is the source of truth once built). Base URL = `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`). Envelope: success `{ data, meta?: { page, pageSize, total } }`, errors normalized to `ApiError` (`code` from `{ error: { code, message } }`, or `NETWORK_ERROR` / `HTTP_<status>`). Multipart upload needs the platform FormData (uri/name/type) — never set `Content-Type` manually. TanStack Query v5 providers live in `app/_layout.tsx`; hooks in `hooks/` (`useFiles`, `useSearch`, `useUpload`, OCR jobs).
+121
View File
@@ -0,0 +1,121 @@
import type {
ApiData,
ApiErrorBody,
FileDto,
FolderDto,
ListFilesParams,
OcrJob,
} from './types';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
export const DEFAULT_TIMEOUT_MS = 15_000;
type QueryParams = Record<string, string | number | boolean | undefined | null>;
function toQuery(params?: QueryParams): string {
if ( !params ) return '';
const search = new URLSearchParams();
for ( const [key, value] of Object.entries(params) ) {
if ( value === undefined || value === null ) continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `?${query}` : '';
}
export class ApiError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'ApiError';
this.code = code;
}
}
async function request<T>(
path: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS
): Promise<ApiData<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}${path}`, {
...init,
signal: controller.signal,
});
} catch {
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
}
const body = (await response.json().catch(() => null)) as
| ApiData<T>
| ApiErrorBody
| null;
if ( !response.ok ) {
const error = body != null && 'error' in body ? body.error : null;
throw new ApiError(
error?.code ?? `HTTP_${response.status}`,
error?.message ?? response.statusText
);
}
return (body ?? { data: undefined as T }) as ApiData<T>;
} finally {
clearTimeout(timer);
}
}
export const api = {
getApiBaseUrl: () => API_BASE_URL,
health: () => request<{ status: string }>('/health'),
listFiles: (params?: ListFilesParams) =>
request<FileDto[]>(`/files${toQuery(params)}`),
getFile: (id: string) =>
request<FileDto>(`/files/${encodeURIComponent(id)}`),
deleteFile: (id: string) =>
request<{ id: string }>(`/files/${encodeURIComponent(id)}`, {
method: 'DELETE',
}),
searchFiles: (q: string, params?: QueryParams) =>
request<FileDto[]>(`/files/search${toQuery({ q, ...params })}`),
listFolders: () => request<FolderDto[]>('/files/folders'),
uploadFile: (
file: { uri: string; name: string; mimeType: string },
folderId?: string | null
) => {
const form = new FormData();
form.append('file', {
uri: file.uri,
name: file.name,
type: file.mimeType,
} as unknown as Blob);
if ( folderId ) form.append('folderId', folderId);
return request<FileDto>('/files/upload', {
method: 'POST',
body: form,
});
},
createOcrJob: (fileId: string) =>
request<OcrJob>('/ocr/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
}),
getOcrJob: (id: string) =>
request<OcrJob>(`/ocr/jobs/${encodeURIComponent(id)}`),
};
+53
View File
@@ -0,0 +1,53 @@
export type ApiMeta = {
page: number;
pageSize: number;
total: number;
};
export type ApiData<T> = {
data: T;
meta?: ApiMeta;
};
export type ApiErrorBody = {
error: {
code: string;
message: string;
};
};
export type FileDto = {
id: string;
name: string;
size: number;
mimeType?: string | null;
folderId?: string | null;
tags?: string[];
createdAt?: string;
updatedAt?: string;
};
export type FolderDto = {
id: string;
name: string;
parentId?: string | null;
};
export type OcrJobStatus = 'queued' | 'processing' | 'done' | 'failed';
export type OcrJob = {
id: string;
status: OcrJobStatus;
text?: string | null;
error?: string | null;
};
export type ListParams = {
page?: number;
pageSize?: number;
sort?: string;
};
export type ListFilesParams = ListParams & {
folderId?: string | null;
};
+15 -3
View File
@@ -1,10 +1,22 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { AuthProvider } from '../context/AuthContext'; import { AuthProvider } from '../context/AuthContext';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
},
},
});
export default function RootLayout() { export default function RootLayout() {
return ( return (
<AuthProvider> <QueryClientProvider client={queryClient}>
<Stack /> <AuthProvider>
</AuthProvider> <Stack />
</AuthProvider>
</QueryClientProvider>
); );
} }
+119
View File
@@ -0,0 +1,119 @@
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { getFolder, getFolderFolders, getFiles } from '../../services/localStorage';
import type { StoredFile, StoredFolder } from '../../services/db/types';
type Row = {
key: string;
kind: 'folder' | 'file';
label: string;
meta: string;
resourceId: string;
};
function formatSize(bytes: number): string {
if ( bytes < 1024 ) return `${bytes} o`;
if ( bytes < 1024 * 1024 ) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
export default function FolderScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const [folder, setFolder] = useState<StoredFolder | null>(null);
const [subfolders, setSubfolders] = useState<StoredFolder[]>([]);
const [files, setFiles] = useState<StoredFile[]>([]);
const load = useCallback(async () => {
const current = await getFolder(id);
if ( !current ) {
router.back();
return;
}
setFolder(current);
setSubfolders(await getFolderFolders(id));
setFiles(await getFiles(id));
}, [id, router]);
useFocusEffect(
useCallback(() => {
load();
}, [load])
);
const rows: Row[] = [
...subfolders.map((f) => ({
key: f.resource_id,
kind: 'folder' as const,
label: f.name,
meta: 'Dossier',
resourceId: f.resource_id,
})),
...files.map((f) => ({
key: f.resource_id,
kind: 'file' as const,
label: f.name,
meta: formatSize(f.size),
resourceId: f.resource_id,
})),
];
return (
<View style={styles.container}>
<Stack.Screen options={{ title: folder?.name ?? 'Dossier' }} />
<FlatList
data={rows}
keyExtractor={(item) => item.key}
renderItem={({ item }) => (
<Pressable
style={styles.row}
onPress={() => item.kind === 'folder' && router.push(`/folder/${item.resourceId}`)}
>
<Text style={item.kind === 'folder' ? styles.folderTitle : styles.fileTitle}>
{item.kind === 'folder' ? `${item.label}/` : item.label}
</Text>
<Text style={styles.rowMeta}>{item.meta}</Text>
</Pressable>
)}
ListEmptyComponent={
<Text style={styles.empty}>
Dossier vide le prochain syncDevice l'actualisera.
</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 16,
},
row: {
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#ddd',
},
folderTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1a73e8',
},
fileTitle: {
fontSize: 16,
},
rowMeta: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
empty: {
color: '#888',
textAlign: 'center',
marginTop: 24,
},
});
+62 -32
View File
@@ -1,44 +1,65 @@
import { useFocusEffect, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { pickDirectory } from '../services/safDirectory';
import { saveDirectory, getFolders } from '../services/localStorage';
import { syncRoot } from '../features/syncDevice'; import { syncRoot } from '../features/syncDevice';
import { pickDirectory } from '../services/safDirectory';
import { getFolders, saveDirectory } from '../services/localStorage';
import type { StoredFolder } from '../services/db/types';
export default function Index() { export default function Index() {
const [folders, setFolders] = useState<string[]>([]); const router = useRouter();
const [roots, setRoots] = useState<StoredFolder[]>([]);
useEffect(() => { const load = useCallback(async () => {
(async () => { const saved = await getFolders();
const saved = await getFolders(); setRoots(saved.filter((folder) => folder.parent_resource_id === null));
setFolders(saved.map((folder) => folder.name));
})();
}, []); }, []);
useFocusEffect(
useCallback(() => {
load();
}, [load])
);
const handlePickDirectory = async () => { const handlePickDirectory = async () => {
const folder = await pickDirectory(); const folder = await pickDirectory();
if ( !folder ) return; if ( !folder ) return;
const saved = await saveDirectory(folder); const saved = await saveDirectory(folder);
setFolders((prev) => [...prev, folder.name]);
try { try {
const result = await syncRoot(saved.resource_id); const result = await syncRoot(saved.resource_id);
console.info('walk', JSON.stringify(result)); console.info('walk', JSON.stringify(result));
} catch (error) { } catch (error) {
console.warn('walk failed', error); console.warn('walk failed', error);
} }
await load();
}; };
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>Dot.</Text>
<Pressable style={styles.button} onPress={handlePickDirectory}>
<Text style={styles.buttonLabel}>Ajouter un dossier</Text>
</Pressable>
{folders.map((name) => (
<Text key={name} style={styles.folder}>{name}</Text>
))}
<StatusBar style="auto" /> <StatusBar style="auto" />
<Pressable style={styles.button} onPress={handlePickDirectory}>
<Text style={styles.buttonText}>Ajouter un dossier</Text>
</Pressable>
<FlatList
data={roots}
keyExtractor={(item) => item.resource_id}
renderItem={({ item }) => (
<Pressable
style={styles.row}
onPress={() => router.push(`/folder/${item.resource_id}`)}
>
<Text style={styles.rowTitle}>{item.name}</Text>
<Text style={styles.rowMeta}>{item.syncStatus}</Text>
</Pressable>
)}
ListEmptyComponent={
<Text style={styles.empty}>
Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.
</Text>
}
/>
</View> </View>
); );
} }
@@ -47,28 +68,37 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#fff', backgroundColor: '#fff',
alignItems: 'center', padding: 16,
justifyContent: 'center',
},
title: {
fontSize: 32,
fontWeight: '600',
}, },
button: { button: {
marginTop: 24, backgroundColor: '#1a73e8',
paddingHorizontal: 20,
paddingVertical: 12, paddingVertical: 12,
backgroundColor: '#0057ff',
borderRadius: 8, borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
}, },
buttonLabel: { buttonText: {
color: '#fff', color: '#fff',
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
}, },
folder: { row: {
marginTop: 8, paddingVertical: 14,
fontSize: 14, borderBottomWidth: StyleSheet.hairlineWidth,
color: '#333', borderBottomColor: '#ddd',
},
rowTitle: {
fontSize: 16,
fontWeight: '500',
},
rowMeta: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
empty: {
color: '#888',
textAlign: 'center',
marginTop: 24,
}, },
}); });
+1 -7
View File
@@ -11,13 +11,7 @@ import {
type StoredFile, type StoredFile,
type StoredFolder, type StoredFolder,
} from '../services/db'; } from '../services/db';
import type { SyncResult } from './syncDevice.types';
export type SyncResult = {
rootUri: string;
folders: number;
files: number;
missing: number;
};
function uriDepth(uri: string): number { function uriDepth(uri: string): number {
return uri.split('/').length; return uri.split('/').length;
+6
View File
@@ -0,0 +1,6 @@
export type SyncResult = {
rootUri: string;
folders: number;
files: number;
missing: number;
};
+31
View File
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import type { FileDto, ListFilesParams } from '../api/types';
export function useFiles(params?: ListFilesParams) {
return useQuery({
queryKey: ['files', params],
queryFn: () => api.listFiles(params),
});
}
export function useFile(id: string) {
return useQuery({
queryKey: ['files', id],
queryFn: () => api.getFile(id),
enabled: id.length > 0,
});
}
export function useFileTags(id: string) {
return useQuery({
queryKey: ['files', id, 'tags'],
queryFn: async (): Promise<string[]> => {
const { data } = await api.getFile(id);
return data.tags ?? [];
},
enabled: id.length > 0,
});
}
export type { FileDto };
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import type { FileDto } from '../api/types';
export function useSearch(q: string, page?: number, pageSize?: number) {
const trimmed = q.trim();
return useQuery({
queryKey: ['search', trimmed, page, pageSize],
queryFn: () => api.searchFiles(trimmed, { page, pageSize }),
enabled: trimmed.length > 0,
});
}
export type { FileDto };
+40
View File
@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import type { FileDto, OcrJob } from '../api/types';
export function useUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
file: { uri: string; name: string; mimeType: string };
folderId?: string | null;
}) => api.uploadFile(input.file, input.folderId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['files'] });
queryClient.invalidateQueries({ queryKey: ['search'] });
return result;
},
});
}
export function useOcrJob(jobId?: string | null) {
return useQuery({
queryKey: ['ocr', jobId],
queryFn: () => api.getOcrJob(jobId!),
enabled: Boolean(jobId),
refetchInterval: (query) =>
query.state.data && query.state.data.data.status === 'done' ? false : 3000,
});
}
export function useCreateOcrJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: string) => api.createOcrJob(fileId),
onSuccess: (result) => {
queryClient.setQueryData(['ocr', result.data.id], result.data);
},
});
}
export type { FileDto, OcrJob };
+27
View File
@@ -8,6 +8,7 @@
"name": "webui", "name": "webui",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.102.8",
"expo": "~57.0.8", "expo": "~57.0.8",
"expo-constants": "~57.0.17", "expo-constants": "~57.0.17",
"expo-file-system": "~57.0.6", "expo-file-system": "~57.0.6",
@@ -2831,6 +2832,32 @@
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/query-core": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.102.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
"integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.102.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "9.6.0", "version": "9.6.0",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
+1
View File
@@ -3,6 +3,7 @@
"version": "2.0.0", "version": "2.0.0",
"main": "expo-router/entry", "main": "expo-router/entry",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.102.8",
"expo": "~57.0.8", "expo": "~57.0.8",
"expo-constants": "~57.0.17", "expo-constants": "~57.0.17",
"expo-file-system": "~57.0.6", "expo-file-system": "~57.0.6",