From d5886c678ba855f44d321f38e54b88677128e862 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 19:54:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20OCR=20r=C3=A9el=20=E2=80=94=20POST?= =?UTF-8?q?=20/ocr/jobs=20+=20GET=20/ocr/jobs/:id=20(tesseract=20syscall,?= =?UTF-8?q?=20jobs=20asynchrones)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ocr/tesseract.go : Engine → Tesseract subprocess (OCR_LANG fra+eng) ; PDF → calque texte via ledongthuc/pdf (go.mod : nouveau dep) - repository/ocr_jobs.go : queued→processing→done/failed, scoping device, text/error NULLIFés - service/ocr.go : Create valide le fichier (GetFile), queue + goroutine de traitement ; physique résolu par glob UPLOAD_DIR//.* ; fail propre (fichier illisible, erreur moteur) - handlers/ocr.go : réels (validate fileId, NOT_FOUND si job d'un autre device), fin 501 OCR - tests end-to-end : cycle queued→done (stub moteur), NOT_FOUND fichier inconnu, scoping device - smoke réel : PNG 'VAULTDROP' → job done text='VAULTDROP' (tesseract installé) - docs/AGENTS : §5 + état des routes (tout V1 réel) --- AGENTS.md | 4 +- backend/cmd/server/main.go | 5 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/handlers/ocr.go | 48 ++++++++++- backend/handlers/ocr_test.go | 143 +++++++++++++++++++++++++++++++ backend/handlers/store.go | 3 + backend/ocr/tesseract.go | 67 +++++++++++++++ backend/repository/ocr_jobs.go | 81 +++++++++++++++++ backend/repository/repository.go | 2 + backend/service/ocr.go | 96 +++++++++++++++++++++ docs/api-v1.md | 10 +-- 12 files changed, 452 insertions(+), 10 deletions(-) create mode 100644 backend/handlers/ocr_test.go create mode 100644 backend/ocr/tesseract.go create mode 100644 backend/repository/ocr_jobs.go create mode 100644 backend/service/ocr.go diff --git a/AGENTS.md b/AGENTS.md index 6839a98..ccd6668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,8 @@ cd mobile && npm run test:db - `config/` — env (`godotenv`, optionnel) + defaults: `PORT`, `DATABASE_URL`, `UPLOAD_DIR`, `MAX_FILE_SIZE_MB`, `OCR_LANG`, secret paseto - `models/` — domain entities (users, devices, documents/resources, clients) - `service/` — business logic (permissions, upload, create folder, move, **sync outbox + snapshot**) -- `handlers/` — HTTP handlers (health, devices register + paseto, files CRUD/upload/search, folders, **sync/ops + sync/permissions** — réels ; OCR stubs 501) -- `repository/` — Postgres persistence réelle (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, **search, move, rename, root-name unique index**, `repository.Devices.Upsert`, `repository.Operations` : trace outbox idempotente `(device_id, operation_id)`, `ListOwned` pour le snapshot) ; IDs sont TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`) +- `handlers/` — HTTP handlers (health, devices register + paseto, files CRUD/upload/search, folders, **sync/ops + sync/permissions, ocr/jobs** — réels) +- `repository/` — Postgres persistence réelle (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, **search, move, rename, root-name unique index**, `repository.Devices.Upsert`, `repository.Operations` : trace outbox idempotente `(device_id, operation_id)`, `ListOwned` pour le snapshot, `repository.OcrJobs` : jobs queued→processing→done/failed) ; IDs sont TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`) - `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible) ; `dbtest/` — helper cross-package pour les tests repo/handlers (crée la DB test si absente, reset schema, migrate ; skip si PG down) - `ocr/` — OCR engine behind an interface (Tesseract system call, `OCR_LANG` défaut `fra+eng`) - Response helpers: `pkg/api/response.go` diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0ab7566..4dd8557 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "github.com/vaultdrop/backend/config" "github.com/vaultdrop/backend/db" "github.com/vaultdrop/backend/handlers" + "github.com/vaultdrop/backend/ocr" "github.com/vaultdrop/backend/pkg/auth" "github.com/vaultdrop/backend/repository" "github.com/vaultdrop/backend/service" @@ -42,11 +43,13 @@ func main() { } handlers.Auth = authManager + repo := repository.NewRepository(conn) handlers.Store = service.NewResources( - repository.NewRepository(conn), + repo, cfg.UploadDir, cfg.MaxFileSizeMB*1024*1024, ) + handlers.Ocr = service.NewOcr(repo, cfg.UploadDir, cfg.OcrLang, ocr.NewTesseract()) if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil { log.Fatalln(err) diff --git a/backend/go.mod b/backend/go.mod index c7fcc94..ad870c3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -25,6 +25,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/backend/go.sum b/backend/go.sum index 63ec109..ddc86ea 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -69,6 +69,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU= +github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= diff --git a/backend/handlers/ocr.go b/backend/handlers/ocr.go index 9520364..36e466e 100644 --- a/backend/handlers/ocr.go +++ b/backend/handlers/ocr.go @@ -1,9 +1,53 @@ package handlers import ( + "net/http" + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/pkg/api" + "github.com/vaultdrop/backend/repository" ) -func OcrJobsCreate(c *gin.Context) { api.NotImplemented(c) } -func OcrJobsGet(c *gin.Context) { api.NotImplemented(c) } +type ocrJobRequest struct { + FileID string `json:"fileId"` +} + +func OcrJobsCreate(c *gin.Context) { + if Store == nil || Ocr == nil { + api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") + return + } + deviceID := c.GetString(DeviceIDKey) + + var req ocrJobRequest + if err := c.ShouldBindJSON(&req); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "invalid request") + return + } + + job, err := Ocr.Create(deviceID, req.FileID) + if err != nil { + writeError(c, err) + return + } + api.OK(c, job) +} + +func OcrJobsGet(c *gin.Context) { + if Ocr == nil { + api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized") + return + } + deviceID := c.GetString(DeviceIDKey) + job, err := Ocr.Get(deviceID, c.Param("id")) + if err != nil { + if err == repository.ErrJobNotFound { + api.Error(c, http.StatusNotFound, "NOT_FOUND", "ocr job not found") + return + } + writeError(c, err) + return + } + api.OK(c, job) +} diff --git a/backend/handlers/ocr_test.go b/backend/handlers/ocr_test.go new file mode 100644 index 0000000..d2ebbae --- /dev/null +++ b/backend/handlers/ocr_test.go @@ -0,0 +1,143 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/vaultdrop/backend/dbtest" + "github.com/vaultdrop/backend/handlers" + "github.com/vaultdrop/backend/pkg/auth" + "github.com/vaultdrop/backend/repository" + "github.com/vaultdrop/backend/service" +) + +type stubEngine struct { + text string + err error +} + +func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string, error) { + if s.err != nil { + return "", s.err + } + return s.text, nil +} + +func setupOcr(t *testing.T) (*gin.Engine, string) { + t.Helper() + conn := dbtest.OpenTestDatabase(t, handlersTestURL) + repo := repository.NewRepository(conn) + uploadDir := t.TempDir() + store := service.NewResources(repo, uploadDir, 100*1024) + + manager, _ := auth.NewManager("test-secret") + handlers.Auth = manager + handlers.Store = store + handlers.Ocr = service.NewOcr(repo, uploadDir, "fra+eng", stubEngine{text: "HELLO OCR"}) + + gin.SetMode(gin.TestMode) + r := gin.New() + handlers.RegisterRoutes(r) + return r, uploadDir +} + +type ocrJobDTO struct { + ID string `json:"id"` + Status string `json:"status"` + Text string `json:"text"` + Error string `json:"error"` +} + +func waitTerminal(t *testing.T, r *gin.Engine, token, jobID string) ocrJobDTO { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/ocr/jobs/"+jobID, token, nil, "") + env := expectOK(t, rec, "ocr-get") + var job ocrJobDTO + if err := json.Unmarshal(env.Data, &job); err != nil { + t.Fatalf("ocr-get: unmarshal: %v", err) + } + if job.Status == "done" || job.Status == "failed" { + return job + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("job OCR jamais terminal") + return ocrJobDTO{} +} + +func TestOcrJobsLifecycle(t *testing.T) { + r, uploadDir := setupOcr(t) + device := repository.NewID() + token := registerDevice(t, r, device) + + // Fichier + fichier physique (simule UPLOAD_DIR//.txt) + fileID := repository.NewID() + if err := os.MkdirAll(filepath.Join(uploadDir, device), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(uploadDir, device, fileID+".txt"), []byte("ignored by stub"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + repo := handlers.Store.Repository + if err := repo.Resources.InsertFile(device, fileID, "scan.png", "", 128, nil, nil); err != nil { + t.Fatalf("insert: %v", err) + } + + // Création du job + body, _ := json.Marshal(map[string]string{"fileId": fileID}) + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/ocr/jobs", token, body, "application/json") + env := expectOK(t, rec, "ocr-create") + var created ocrJobDTO + if err := json.Unmarshal(env.Data, &created); err != nil { + t.Fatalf("ocr-create: unmarshal: %v", err) + } + if created.Status != "queued" || created.ID == "" { + t.Errorf("job attendu queued: %+v", created) + } + + // Poll jusqu'au terminal + job := waitTerminal(t, r, token, created.ID) + if job.Status != "done" || job.Text != "HELLO OCR" { + t.Errorf("job terminal: %+v", job) + } + + // Fichier inconnu → NOT_FOUND + rec, _ = doRequest(t, r, http.MethodPost, "/api/v1/ocr/jobs", token, bodyFor(repository.NewID()), "application/json") + expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-missing-file") +} + +func TestOcrJobsScopedByDevice(t *testing.T) { + r, _ := setupOcr(t) + deviceA := repository.NewID() + deviceB := repository.NewID() + tokenA := registerDevice(t, r, deviceA) + tokenB := registerDevice(t, r, deviceB) + + fileID := repository.NewID() + if err := handlers.Store.Repository.Resources.InsertFile(deviceA, fileID, "scan.png", "", 128, nil, nil); err != nil { + t.Fatalf("insert: %v", err) + } + body, _ := json.Marshal(map[string]string{"fileId": fileID}) + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/ocr/jobs", tokenA, body, "application/json") + env := expectOK(t, rec, "ocr-create") + var created ocrJobDTO + _ = json.Unmarshal(env.Data, &created) + + // Un autre device ne voit pas le job + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/ocr/jobs/"+created.ID, tokenB, nil, "") + expectError(t, rec, http.StatusNotFound, "NOT_FOUND", "ocr-other-device") +} + +func bodyFor(id string) []byte { + b, _ := json.Marshal(map[string]string{"fileId": id}) + return b +} diff --git a/backend/handlers/store.go b/backend/handlers/store.go index 7a44830..8c4c757 100644 --- a/backend/handlers/store.go +++ b/backend/handlers/store.go @@ -13,6 +13,9 @@ import ( // (cmd/server). Nil until then. var Store *service.Resources +// Ocr queues/reads OCR jobs; set once at startup alongside Store. +var Ocr *service.Ocr + // writeError maps repository/service sentinels to contract error codes. func writeError(c *gin.Context, err error) { switch { diff --git a/backend/ocr/tesseract.go b/backend/ocr/tesseract.go new file mode 100644 index 0000000..041e272 --- /dev/null +++ b/backend/ocr/tesseract.go @@ -0,0 +1,67 @@ +package ocr + +import ( + "bytes" + "context" + "errors" + "os/exec" + "path/filepath" + "strings" + + "github.com/ledongthuc/pdf" +) + +// TesseractEngine runs the `tesseract` binary in a subprocess. +// Images are OCR'd directly; PDFs have their text layer extracted first +// (scanned PDFs → empty text, no rendering pipeline in V1). +type TesseractEngine struct{} + +func NewTesseract() *TesseractEngine { return &TesseractEngine{} } + +// ExtractText implements Engine. +func (t *TesseractEngine) ExtractText(ctx context.Context, filePath, lang string) (string, error) { + if strings.ToLower(filepath.Ext(filePath)) == ".pdf" { + return extractPDFText(filePath) + } + return runTesseract(ctx, filePath, lang) +} + +func runTesseract(ctx context.Context, filePath, lang string) (string, error) { + cmd := exec.CommandContext(ctx, "tesseract", filePath, "stdout", "-l", lang) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + return "", ctx.Err() + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", errors.New("tesseract: " + msg) + } + return strings.TrimSpace(stdout.String()), nil +} + +func extractPDFText(filePath string) (string, error) { + f, r, err := pdf.Open(filePath) + if err != nil { + return "", errors.New("pdf: " + err.Error()) + } + defer f.Close() + var builder strings.Builder + for i := 1; i <= r.NumPage(); i++ { + p := r.Page(i) + if p.V.IsNull() { + continue + } + plain, err := p.GetPlainText(nil) + if err != nil { + continue + } + builder.WriteString(plain) + builder.WriteString("\n") + } + return strings.TrimSpace(builder.String()), nil +} diff --git a/backend/repository/ocr_jobs.go b/backend/repository/ocr_jobs.go new file mode 100644 index 0000000..f1dff71 --- /dev/null +++ b/backend/repository/ocr_jobs.go @@ -0,0 +1,81 @@ +package repository + +import ( + "database/sql" + "errors" +) + +// OcrJobRow maps ocr_jobs. +type OcrJobRow struct { + ID string + FileID string + DeviceID string + Status string + Text *string + Error *string +} + +// ErrJobNotFound marks an OCR job absent or owned by another device. +var ErrJobNotFound = errors.New("ocr job not found") + +type OcrJobs struct{ DB *sql.DB } + +func (o *OcrJobs) Create(jobID, deviceID, fileID string) error { + _, err := o.DB.Exec( + `INSERT INTO ocr_jobs (job_id, device_id, file_id) VALUES ($1, $2, $3)`, + jobID, deviceID, fileID, + ) + return err +} + +// Get returns a job scoped by device (no-rows → ErrJobNotFound). +func (o *OcrJobs) Get(deviceID, jobID string) (OcrJobRow, error) { + var row OcrJobRow + var text, errMsg sql.NullString + err := o.DB.QueryRow( + `SELECT job_id, file_id, device_id, status, + NULLIF(text, ''), NULLIF(error, '') + FROM ocr_jobs WHERE job_id = $1 AND device_id = $2`, + jobID, deviceID, + ).Scan(&row.ID, &row.FileID, &row.DeviceID, &row.Status, &text, &errMsg) + if err == sql.ErrNoRows { + return OcrJobRow{}, ErrJobNotFound + } + if err != nil { + return OcrJobRow{}, err + } + if text.Valid { + row.Text = &text.String + } + if errMsg.Valid { + row.Error = &errMsg.String + } + return row, nil +} + +func (o *OcrJobs) TouchProcessing(deviceID, jobID string) error { + _, err := o.DB.Exec( + `UPDATE ocr_jobs SET status = 'processing', started_at = NOW() + WHERE job_id = $1 AND device_id = $2 AND status = 'queued'`, + jobID, deviceID, + ) + return err +} + +func (o *OcrJobs) Complete(deviceID, jobID, text string) error { + _, err := o.DB.Exec( + `UPDATE ocr_jobs SET status = 'done', text = NULLIF($3, ''), started_at = COALESCE(started_at, NOW()), completed_at = NOW() + WHERE job_id = $1 AND device_id = $2`, + jobID, deviceID, text, + ) + return err +} + +func (o *OcrJobs) Fail(deviceID, jobID, message string) error { + _, err := o.DB.Exec( + `UPDATE ocr_jobs SET status = 'failed', error = NULLIF($3, ''), started_at = COALESCE(started_at, NOW()), completed_at = NOW() + WHERE job_id = $1 AND device_id = $2`, + jobID, deviceID, message, + ) + return err +} diff --git a/backend/repository/repository.go b/backend/repository/repository.go index bb841e7..588b0b3 100644 --- a/backend/repository/repository.go +++ b/backend/repository/repository.go @@ -9,6 +9,7 @@ type Repository struct { Resources *Resources Devices *Devices Operations *Operations + OcrJobs *OcrJobs } func NewRepository(conn *sql.DB) *Repository { @@ -16,5 +17,6 @@ func NewRepository(conn *sql.DB) *Repository { Resources: &Resources{DB: conn}, Devices: &Devices{DB: conn}, Operations: &Operations{DB: conn}, + OcrJobs: &OcrJobs{DB: conn}, } } diff --git a/backend/service/ocr.go b/backend/service/ocr.go new file mode 100644 index 0000000..452d133 --- /dev/null +++ b/backend/service/ocr.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "path/filepath" + "strings" + + "github.com/vaultdrop/backend/ocr" + "github.com/vaultdrop/backend/repository" +) + +// OcrJobDTO serializes exactly as mobile/api/types.ts OcrJob. +type OcrJobDTO struct { + ID string `json:"id"` + Status string `json:"status"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` +} + +// Ocr queues OCR jobs and processes them asynchronously (V1 : goroutine par +// job ; le client poll GET /ocr/jobs/:id toutes les 3s). +type Ocr struct { + Repository *repository.Repository + UploadDir string + Lang string + Engine ocr.Engine +} + +func NewOcr(repo *repository.Repository, uploadDir, lang string, engine ocr.Engine) *Ocr { + return &Ocr{Repository: repo, UploadDir: uploadDir, Lang: lang, Engine: engine} +} + +// Create validates the file, queued the job, and starts processing. +func (o *Ocr) Create(deviceID, fileID string) (OcrJobDTO, error) { + if _, err := o.Repository.Resources.GetFile(deviceID, fileID); err != nil { + return OcrJobDTO{}, err + } + jobID := repository.NewID() + if err := o.Repository.OcrJobs.Create(jobID, deviceID, fileID); err != nil { + return OcrJobDTO{}, err + } + go o.process(deviceID, jobID, fileID) + return OcrJobDTO{ID: jobID, Status: "queued"}, nil +} + +func (o *Ocr) Get(deviceID, jobID string) (OcrJobDTO, error) { + row, err := o.Repository.OcrJobs.Get(deviceID, jobID) + if err != nil { + return OcrJobDTO{}, err + } + return toOcrJobDTO(row), nil +} + +func (o *Ocr) process(deviceID, jobID, fileID string) { + ctx := context.Background() + if err := o.Repository.OcrJobs.TouchProcessing(deviceID, jobID); err != nil { + return + } + path, err := o.physicalPath(deviceID, fileID) + if err != nil { + _ = o.Repository.OcrJobs.Fail(deviceID, jobID, "file not readable") + return + } + text, err := o.Engine.ExtractText(ctx, path, o.Lang) + if err != nil { + _ = o.Repository.OcrJobs.Fail(deviceID, jobID, err.Error()) + return + } + _ = o.Repository.OcrJobs.Complete(deviceID, jobID, text) +} + +// physicalPath resolves UPLOAD_DIR//. — the ext +// is chosen at upload time, so the actual file is matched by prefix. +func (o *Ocr) physicalPath(deviceID, fileID string) (string, error) { + matches, err := filepath.Glob(filepath.Join(o.UploadDir, deviceID, fileID+".*")) + if err != nil { + return "", err + } + for _, m := range matches { + if strings.HasPrefix(filepath.Base(m), fileID+".") { + return m, nil + } + } + return "", repository.ErrNotFound +} + +func toOcrJobDTO(row repository.OcrJobRow) OcrJobDTO { + dto := OcrJobDTO{ID: row.ID, Status: row.Status} + if row.Text != nil { + dto.Text = *row.Text + } + if row.Error != nil { + dto.Error = *row.Error + } + return dto +} diff --git a/docs/api-v1.md b/docs/api-v1.md index c14107e..3893c1b 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -64,10 +64,10 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: ## 5. OCR -- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone**. -- `GET /ocr/jobs/:id` → statut. Le mobile **poll toutes les 3s** jusqu'à `done`/`failed` (`hooks/useUpload.ts`). -- Moteur : **Tesseract en appel système**, langue configurable `OCR_LANG` (défaut `fra+eng`). Un stub qui répond indéfiniment `status: "pending"` est un comportement temporaire acceptable (le client ne casse pas). -- Extraction texte PDF : `ledongthuc/pdf` (déjà en go.mod). +- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone** (goroutine par job côté serveur, V1). +- `GET /ocr/jobs/:id` → statut. Le mobile **poll toutes les 3s** jusqu'à `done`/`failed` (`hooks/useUpload.ts`). Cycle : `queued → processing → done | failed` ; `done` renvoie `text`, `failed` renvoie `error`. +- Moteur : **Tesseract en appel système** (`ocr/tesseract.go`), langue `OCR_LANG` (défaut `fra+eng`). Les images sont passées directement à `tesseract` ; les **PDF** subissent une extraction du calque texte (`ledongthuc/pdf`, déjà en go.mod) — un PDF scanné produit un texte vide plutôt qu'un rendu/OCR (hors scope V1). +- `fileId` inconnu/pas du device → `NOT_FOUND`. Fichier physique introuvable (ex. suppression manuelle sous `UPLOAD_DIR`) → job `failed` `"file not readable"`. ## 6. Contrat de sync (outbox + snapshot) @@ -133,4 +133,4 @@ type ResourcePermission = { ## 7. Codes d'erreur courants -`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : files CRUD/upload/search, folders, devices, health, **sync/ops + sync/permissions** sont réels ; `ocr/*` en queue), `FILE_TOO_LARGE` (413), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(owner_id, name) WHERE parent_id IS NULL`), `NETWORK_ERROR` (côté client), `HTTP_` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue. Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé. \ No newline at end of file +`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : **toutes les routes V1 sont réelles** : files CRUD/upload/search, folders, devices, health, sync/ops, sync/permissions, ocr/jobs), `FILE_TOO_LARGE` (413), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(owner_id, name) WHERE parent_id IS NULL`), `NETWORK_ERROR` (côté client), `HTTP_` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé. \ No newline at end of file