From 8ff2339c8ab311a4e8280b4566a775b22045d6a0 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 06:46:22 +0200 Subject: [PATCH] better iterface --- AGENTS.md | 5 +- mobile/AGENTS.md | 4 + mobile/api/client.ts | 121 ++++++++++++++++++++++++++++ mobile/api/types.ts | 53 ++++++++++++ mobile/app/_layout.tsx | 18 ++++- mobile/app/folder/[id].tsx | 119 +++++++++++++++++++++++++++ mobile/app/index.tsx | 94 +++++++++++++-------- mobile/features/syncDevice.ts | 8 +- mobile/features/syncDevice.types.ts | 6 ++ mobile/hooks/useFiles.ts | 31 +++++++ mobile/hooks/useSearch.ts | 14 ++++ mobile/hooks/useUpload.ts | 40 +++++++++ mobile/package-lock.json | 27 +++++++ mobile/package.json | 1 + 14 files changed, 498 insertions(+), 43 deletions(-) create mode 100644 mobile/api/client.ts create mode 100644 mobile/api/types.ts create mode 100644 mobile/app/folder/[id].tsx create mode 100644 mobile/features/syncDevice.types.ts create mode 100644 mobile/hooks/useFiles.ts create mode 100644 mobile/hooks/useSearch.ts create mode 100644 mobile/hooks/useUpload.ts diff --git a/AGENTS.md b/AGENTS.md index 39e50d9..29b6384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,8 +48,11 @@ cd mobile && npm run test:db - `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) - `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 -- 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 diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md index f920428..0ee7ca1 100644 --- a/mobile/AGENTS.md +++ b/mobile/AGENTS.md @@ -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`. - 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. + +# 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_`). 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). diff --git a/mobile/api/client.ts b/mobile/api/client.ts new file mode 100644 index 0000000..dd4f47c --- /dev/null +++ b/mobile/api/client.ts @@ -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; + +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( + path: string, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise> { + 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 + | 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; + } finally { + clearTimeout(timer); + } +} + +export const api = { + getApiBaseUrl: () => API_BASE_URL, + + health: () => request<{ status: string }>('/health'), + + listFiles: (params?: ListFilesParams) => + request(`/files${toQuery(params)}`), + + getFile: (id: string) => + request(`/files/${encodeURIComponent(id)}`), + + deleteFile: (id: string) => + request<{ id: string }>(`/files/${encodeURIComponent(id)}`, { + method: 'DELETE', + }), + + searchFiles: (q: string, params?: QueryParams) => + request(`/files/search${toQuery({ q, ...params })}`), + + listFolders: () => request('/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('/files/upload', { + method: 'POST', + body: form, + }); + }, + + createOcrJob: (fileId: string) => + request('/ocr/jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileId }), + }), + + getOcrJob: (id: string) => + request(`/ocr/jobs/${encodeURIComponent(id)}`), +}; \ No newline at end of file diff --git a/mobile/api/types.ts b/mobile/api/types.ts new file mode 100644 index 0000000..9277702 --- /dev/null +++ b/mobile/api/types.ts @@ -0,0 +1,53 @@ +export type ApiMeta = { + page: number; + pageSize: number; + total: number; +}; + +export type ApiData = { + 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; +}; \ No newline at end of file diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index dcc8f5d..e9ab433 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,10 +1,22 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Stack } from 'expo-router'; import { AuthProvider } from '../context/AuthContext'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 2, + }, + }, +}); + export default function RootLayout() { return ( - - - + + + + + ); } \ No newline at end of file diff --git a/mobile/app/folder/[id].tsx b/mobile/app/folder/[id].tsx new file mode 100644 index 0000000..59a835e --- /dev/null +++ b/mobile/app/folder/[id].tsx @@ -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(null); + const [subfolders, setSubfolders] = useState([]); + const [files, setFiles] = useState([]); + + 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 ( + + + item.key} + renderItem={({ item }) => ( + item.kind === 'folder' && router.push(`/folder/${item.resourceId}`)} + > + + {item.kind === 'folder' ? `${item.label}/` : item.label} + + {item.meta} + + )} + ListEmptyComponent={ + + Dossier vide — le prochain syncDevice l'actualisera. + + } + /> + + ); +} + +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, + }, +}); \ No newline at end of file diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index c3ca94e..cfb233a 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,44 +1,65 @@ +import { useFocusEffect, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { useEffect, useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { pickDirectory } from '../services/safDirectory'; -import { saveDirectory, getFolders } from '../services/localStorage'; +import { useCallback, useState } from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; 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() { - const [folders, setFolders] = useState([]); + const router = useRouter(); + const [roots, setRoots] = useState([]); - useEffect(() => { - (async () => { - const saved = await getFolders(); - setFolders(saved.map((folder) => folder.name)); - })(); + const load = useCallback(async () => { + const saved = await getFolders(); + setRoots(saved.filter((folder) => folder.parent_resource_id === null)); }, []); + useFocusEffect( + useCallback(() => { + load(); + }, [load]) + ); + const handlePickDirectory = async () => { const folder = await pickDirectory(); if ( !folder ) return; const saved = await saveDirectory(folder); - setFolders((prev) => [...prev, folder.name]); try { const result = await syncRoot(saved.resource_id); console.info('walk', JSON.stringify(result)); } catch (error) { console.warn('walk failed', error); } + await load(); }; return ( - Dot. - - Ajouter un dossier - - {folders.map((name) => ( - {name} - ))} + + Ajouter un dossier + + item.resource_id} + renderItem={({ item }) => ( + router.push(`/folder/${item.resource_id}`)} + > + {item.name} + {item.syncStatus} + + )} + ListEmptyComponent={ + + Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier. + + } + /> ); } @@ -47,28 +68,37 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: 32, - fontWeight: '600', + padding: 16, }, button: { - marginTop: 24, - paddingHorizontal: 20, + backgroundColor: '#1a73e8', paddingVertical: 12, - backgroundColor: '#0057ff', borderRadius: 8, + alignItems: 'center', + marginBottom: 16, }, - buttonLabel: { + buttonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, - folder: { - marginTop: 8, - fontSize: 14, - color: '#333', + row: { + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#ddd', + }, + rowTitle: { + fontSize: 16, + fontWeight: '500', + }, + rowMeta: { + fontSize: 12, + color: '#888', + marginTop: 2, + }, + empty: { + color: '#888', + textAlign: 'center', + marginTop: 24, }, }); \ No newline at end of file diff --git a/mobile/features/syncDevice.ts b/mobile/features/syncDevice.ts index d1fa817..40a3d68 100644 --- a/mobile/features/syncDevice.ts +++ b/mobile/features/syncDevice.ts @@ -11,13 +11,7 @@ import { type StoredFile, type StoredFolder, } from '../services/db'; - -export type SyncResult = { - rootUri: string; - folders: number; - files: number; - missing: number; -}; +import type { SyncResult } from './syncDevice.types'; function uriDepth(uri: string): number { return uri.split('/').length; diff --git a/mobile/features/syncDevice.types.ts b/mobile/features/syncDevice.types.ts new file mode 100644 index 0000000..07bdac7 --- /dev/null +++ b/mobile/features/syncDevice.types.ts @@ -0,0 +1,6 @@ +export type SyncResult = { + rootUri: string; + folders: number; + files: number; + missing: number; +}; \ No newline at end of file diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts new file mode 100644 index 0000000..03162ba --- /dev/null +++ b/mobile/hooks/useFiles.ts @@ -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 => { + const { data } = await api.getFile(id); + return data.tags ?? []; + }, + enabled: id.length > 0, + }); +} + +export type { FileDto }; \ No newline at end of file diff --git a/mobile/hooks/useSearch.ts b/mobile/hooks/useSearch.ts new file mode 100644 index 0000000..faad007 --- /dev/null +++ b/mobile/hooks/useSearch.ts @@ -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 }; \ No newline at end of file diff --git a/mobile/hooks/useUpload.ts b/mobile/hooks/useUpload.ts new file mode 100644 index 0000000..321e365 --- /dev/null +++ b/mobile/hooks/useUpload.ts @@ -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 }; \ No newline at end of file diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 22d94b2..1c4b7b6 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -8,6 +8,7 @@ "name": "webui", "version": "2.0.0", "dependencies": { + "@tanstack/react-query": "^5.102.8", "expo": "~57.0.8", "expo-constants": "~57.0.17", "expo-file-system": "~57.0.6", @@ -2831,6 +2832,32 @@ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "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": { "version": "9.6.0", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index 924e5e4..dad5b65 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -3,6 +3,7 @@ "version": "2.0.0", "main": "expo-router/entry", "dependencies": { + "@tanstack/react-query": "^5.102.8", "expo": "~57.0.8", "expo-constants": "~57.0.17", "expo-file-system": "~57.0.6",