ocr is working
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/vaultdrop/backend/config"
|
||||
@@ -64,8 +70,30 @@ func main() {
|
||||
)
|
||||
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)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
}
|
||||
// Worker OCR borné (OCR_WORKERS) : file `ocr_jobs`, hors request handlers.
|
||||
go handlers.Ocr.Run(ctx, cfg.OcrWorkers)
|
||||
log.Printf("ocr worker démarré (%d workers)", cfg.OcrWorkers)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: newRouter(),
|
||||
}
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
stop()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
log.Println("arrêt propre")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type ApplicationConfig struct {
|
||||
UploadDir string
|
||||
MaxFileSizeMB int64
|
||||
OcrLang string
|
||||
OcrWorkers int
|
||||
AuthSecret string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
@@ -33,12 +34,18 @@ func LoadApplicationConfig() (*ApplicationConfig, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ocrWorkers, err := getInt("OCR_WORKERS", 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ApplicationConfig{
|
||||
Port: port,
|
||||
DatabaseURL: get("DATABASE_URL", "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_dev?sslmode=disable"),
|
||||
UploadDir: get("UPLOAD_DIR", "./uploads"),
|
||||
MaxFileSizeMB: int64(maxSize),
|
||||
OcrLang: get("OCR_LANG", "fra+eng"),
|
||||
OcrWorkers: ocrWorkers,
|
||||
AuthSecret: get("AUTH_SECRET", "dev-secret-change-me"),
|
||||
AdminUsername: get("ADMIN_USERNAME", ""),
|
||||
AdminPassword: get("ADMIN_PASSWORD", ""),
|
||||
|
||||
@@ -9,7 +9,7 @@ func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"PORT", "DATABASE_URL", "UPLOAD_DIR", "MAX_FILE_SIZE_MB",
|
||||
"OCR_LANG", "AUTH_SECRET", "ADMIN_USERNAME", "ADMIN_PASSWORD",
|
||||
"OCR_LANG", "OCR_WORKERS", "AUTH_SECRET", "ADMIN_USERNAME", "ADMIN_PASSWORD",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
@@ -37,6 +37,9 @@ func TestLoadApplicationDefaults(t *testing.T) {
|
||||
if cfg.OcrLang != "fra+eng" {
|
||||
t.Errorf("OcrLang défaut inattendu: %q", cfg.OcrLang)
|
||||
}
|
||||
if cfg.OcrWorkers != 2 {
|
||||
t.Errorf("OcrWorkers = %d, attendu 2", cfg.OcrWorkers)
|
||||
}
|
||||
if cfg.AuthSecret != "dev-secret-change-me" {
|
||||
t.Errorf("AuthSecret défaut inattendu: %q", cfg.AuthSecret)
|
||||
}
|
||||
@@ -52,6 +55,7 @@ func TestLoadApplicationEnvOverrides(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", "postgres://u:p@host:5433/db?sslmode=disable")
|
||||
t.Setenv("UPLOAD_DIR", "/tmp/up")
|
||||
t.Setenv("OCR_LANG", "eng")
|
||||
t.Setenv("OCR_WORKERS", "4")
|
||||
t.Setenv("AUTH_SECRET", "super-secret")
|
||||
t.Setenv("ADMIN_USERNAME", "root")
|
||||
t.Setenv("ADMIN_PASSWORD", "toor")
|
||||
@@ -61,7 +65,7 @@ func TestLoadApplicationEnvOverrides(t *testing.T) {
|
||||
t.Fatalf("LoadApplicationConfig: %v", err)
|
||||
}
|
||||
if cfg.Port != 9090 || cfg.MaxFileSizeMB != 120 || cfg.DatabaseURL != "postgres://u:p@host:5433/db?sslmode=disable" ||
|
||||
cfg.UploadDir != "/tmp/up" || cfg.OcrLang != "eng" || cfg.AuthSecret != "super-secret" ||
|
||||
cfg.UploadDir != "/tmp/up" || cfg.OcrLang != "eng" || cfg.OcrWorkers != 4 || cfg.AuthSecret != "super-secret" ||
|
||||
cfg.AdminUsername != "root" || cfg.AdminPassword != "toor" {
|
||||
t.Errorf("overrides non appliqués: %+v", cfg)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ func setupOcr(t *testing.T) (*gin.Engine, string, *repository.Repository) {
|
||||
handlers.Store = store
|
||||
handlers.Ocr = service.NewOcr(repo, uploadDir, "fra+eng", stubEngine{text: "HELLO OCR"})
|
||||
|
||||
// Worker OCR borné — le handler n'enfile plus que le job, le traitement
|
||||
// tourne sur un worker de contexte.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go handlers.Ocr.Run(ctx, 1)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
handlers.RegisterRoutes(r)
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OcrJobRow maps ocr_jobs.
|
||||
@@ -18,6 +19,9 @@ type OcrJobRow struct {
|
||||
// ErrJobNotFound marks an OCR job absent or owned by another device.
|
||||
var ErrJobNotFound = errors.New("ocr job not found")
|
||||
|
||||
// ErrNoQueuedJobs marks an OCR queue drained (no job to claim).
|
||||
var ErrNoQueuedJobs = errors.New("no queued ocr jobs")
|
||||
|
||||
type OcrJobs struct{ DB *sql.DB }
|
||||
|
||||
func (o *OcrJobs) Create(jobID, deviceID, fileID string) error {
|
||||
@@ -62,6 +66,49 @@ func (o *OcrJobs) TouchProcessing(deviceID, jobID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ClaimNext atomically picks the oldest queued job (FIFO) and moves it to
|
||||
// `processing`. Safe for concurrent workers: `FOR UPDATE SKIP LOCKED` blocks
|
||||
// the row as part of the same statement. Empty queue → ErrNoQueuedJobs.
|
||||
func (o *OcrJobs) ClaimNext() (OcrJobRow, error) {
|
||||
var row OcrJobRow
|
||||
err := o.DB.QueryRow(
|
||||
`WITH next AS (
|
||||
SELECT job_id FROM ocr_jobs
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at, job_id
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE ocr_jobs
|
||||
SET status = 'processing', started_at = NOW()
|
||||
FROM next
|
||||
WHERE ocr_jobs.job_id = next.job_id
|
||||
RETURNING ocr_jobs.job_id, ocr_jobs.file_id, ocr_jobs.device_id, ocr_jobs.status`,
|
||||
).Scan(&row.ID, &row.FileID, &row.DeviceID, &row.Status)
|
||||
if err == sql.ErrNoRows {
|
||||
return OcrJobRow{}, ErrNoQueuedJobs
|
||||
}
|
||||
if err != nil {
|
||||
return OcrJobRow{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// ResetStaleProcessing requeues jobs stuck in `processing` (worker crash /
|
||||
// processus redémarré) et plus vieux que `olderThan`. Retourne le nb de jobs
|
||||
// requeued.
|
||||
func (o *OcrJobs) ResetStaleProcessing(olderThan time.Duration) (int64, error) {
|
||||
res, err := o.DB.Exec(
|
||||
`UPDATE ocr_jobs SET status = 'queued', started_at = NULL
|
||||
WHERE status = 'processing' AND started_at < $1`,
|
||||
time.Now().Add(-olderThan),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vaultdrop/backend/dbtest"
|
||||
)
|
||||
@@ -90,3 +91,91 @@ func TestOcrJobsGetScopedByDevice(t *testing.T) {
|
||||
t.Errorf("get par un autre device : attendu ErrJobNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOcrJobsClaimNextFifo(t *testing.T) {
|
||||
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||
var jobIDs []string
|
||||
for i := 0; i < 3; i++ {
|
||||
jobID := NewID()
|
||||
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create %d: %v", i, err)
|
||||
}
|
||||
jobIDs = append(jobIDs, jobID)
|
||||
}
|
||||
|
||||
for i, want := range jobIDs {
|
||||
row, err := repo.OcrJobs.ClaimNext()
|
||||
if err != nil {
|
||||
t.Fatalf("claim %d: %v", i, err)
|
||||
}
|
||||
if row.ID != want {
|
||||
t.Errorf("claim %d = %q, attendu %q (FIFO)", i, row.ID, want)
|
||||
}
|
||||
if row.Status != "processing" {
|
||||
t.Errorf("claim %d status = %q, attendu processing", i, row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := repo.OcrJobs.ClaimNext(); !errors.Is(err, ErrNoQueuedJobs) {
|
||||
t.Errorf("queue vide : attendu ErrNoQueuedJobs, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOcrJobsClaimNextSkipsProcessingAndDone(t *testing.T) {
|
||||
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||
job1 := NewID()
|
||||
job2 := NewID()
|
||||
if err := repo.OcrJobs.Create(job1, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create job1: %v", err)
|
||||
}
|
||||
if err := repo.OcrJobs.Create(job2, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create job2: %v", err)
|
||||
}
|
||||
if err := repo.OcrJobs.TouchProcessing(deviceID, job2); err != nil {
|
||||
t.Fatalf("touch job2 processing: %v", err)
|
||||
}
|
||||
|
||||
row, err := repo.OcrJobs.ClaimNext()
|
||||
if err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if row.ID != job1 {
|
||||
t.Errorf("claim = %q, attendu job1 %q", row.ID, job1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOcrJobsResetStaleProcessing(t *testing.T) {
|
||||
repo, _, deviceID, fileID := seedOcrJobFixture(t)
|
||||
jobID := NewID()
|
||||
if err := repo.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if err := repo.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
|
||||
t.Fatalf("touch processing: %v", err)
|
||||
}
|
||||
|
||||
// Started_at = NOW : pas encore stale → aucun requeue.
|
||||
if n, err := repo.OcrJobs.ResetStaleProcessing(time.Minute); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
} else if n != 0 {
|
||||
t.Errorf("reset récent : attendu 0, got %d", n)
|
||||
}
|
||||
|
||||
// Vieillit le started_at puis re-reset → 1 requeue.
|
||||
if _, err := repo.OcrJobs.DB.Exec(`UPDATE ocr_jobs SET started_at = NOW() - interval '2 minutes' WHERE job_id = $1`, jobID); err != nil {
|
||||
t.Fatalf("vieillir started_at: %v", err)
|
||||
}
|
||||
if n, err := repo.OcrJobs.ResetStaleProcessing(time.Minute); err != nil {
|
||||
t.Fatalf("reset stale: %v", err)
|
||||
} else if n != 1 {
|
||||
t.Errorf("reset stale : attendu 1, got %d", n)
|
||||
}
|
||||
|
||||
row, err := repo.OcrJobs.Get(deviceID, jobID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if row.Status != "queued" {
|
||||
t.Errorf("status après reset = %q, attendu queued", row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,21 @@ func (r *Resources) GetFileVisible(userID, resourceID string) (FileRow, error) {
|
||||
return file, err
|
||||
}
|
||||
|
||||
// GetResourceOwner returns the owning user_id of any resource (file or folder).
|
||||
// No-rows → ErrNotFound.
|
||||
func (r *Resources) GetResourceOwner(resourceID string) (string, error) {
|
||||
var ownerID string
|
||||
err := r.DB.QueryRow(
|
||||
`SELECT user_id FROM resources
|
||||
WHERE resource_id = $1 AND deleted_at IS NULL`,
|
||||
resourceID,
|
||||
).Scan(&ownerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return ownerID, err
|
||||
}
|
||||
|
||||
// DeleteFile soft-deletes the file (deleted_at), returning its id.
|
||||
func (r *Resources) DeleteFile(ownerID, resourceID string) (string, error) {
|
||||
result, err := r.DB.Exec(
|
||||
|
||||
+73
-15
@@ -2,13 +2,25 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/vaultdrop/backend/ocr"
|
||||
"github.com/vaultdrop/backend/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// StaleClaimTimeout : un job `processing` plus vieux que ça est ré-enfilé
|
||||
// au boot (worker mort / process redémarré).
|
||||
StaleClaimTimeout = 15 * time.Minute
|
||||
// QueuePollInterval : cadence de scrutation quand la file est vide.
|
||||
QueuePollInterval = time.Second
|
||||
)
|
||||
|
||||
// OcrJobDTO serializes exactly as mobile/api/types.ts OcrJob.
|
||||
type OcrJobDTO struct {
|
||||
ID string `json:"id"`
|
||||
@@ -17,22 +29,24 @@ type OcrJobDTO struct {
|
||||
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).
|
||||
// Ocr queues OCR jobs in `ocr_jobs` (status `queued`) and processes them
|
||||
// asynchronously through a bounded worker ([Run]) — Tesseract ne tourne jamais
|
||||
// dans un request handler. Le client poll GET /ocr/jobs/:id toutes les 3s.
|
||||
type Ocr struct {
|
||||
Repository *repository.Repository
|
||||
UploadDir string
|
||||
Lang string
|
||||
Engine ocr.Engine
|
||||
StaleAfter time.Duration
|
||||
}
|
||||
|
||||
func NewOcr(repo *repository.Repository, uploadDir, lang string, engine ocr.Engine) *Ocr {
|
||||
return &Ocr{Repository: repo, UploadDir: uploadDir, Lang: lang, Engine: engine}
|
||||
return &Ocr{Repository: repo, UploadDir: uploadDir, Lang: lang, Engine: engine, StaleAfter: StaleClaimTimeout}
|
||||
}
|
||||
|
||||
// Create valide la ressource (ownership par user), met le job en file et
|
||||
// lance le traitement. Le job reste scopé par device (le service génère les
|
||||
// jobs du device courant ; l'outbox OCR n'est pas synchronisée entre devices).
|
||||
// Create valide la ressource (ownership par user) et l'enfile uniquement
|
||||
// (status `queued`) — le traitement est délégué au worker [Run] pour ne pas
|
||||
// stresser le serveur. Le job reste scopé par device.
|
||||
func (o *Ocr) Create(userID, deviceID, fileID string) (OcrJobDTO, error) {
|
||||
if _, err := o.Repository.Resources.GetFile(userID, fileID); err != nil {
|
||||
return OcrJobDTO{}, err
|
||||
@@ -41,7 +55,6 @@ func (o *Ocr) Create(userID, deviceID, fileID string) (OcrJobDTO, error) {
|
||||
if err := o.Repository.OcrJobs.Create(jobID, deviceID, fileID); err != nil {
|
||||
return OcrJobDTO{}, err
|
||||
}
|
||||
go o.process(userID, deviceID, jobID, fileID)
|
||||
return OcrJobDTO{ID: jobID, Status: "queued"}, nil
|
||||
}
|
||||
|
||||
@@ -53,22 +66,67 @@ func (o *Ocr) Get(deviceID, jobID string) (OcrJobDTO, error) {
|
||||
return toOcrJobDTO(row), nil
|
||||
}
|
||||
|
||||
func (o *Ocr) process(userID, deviceID, jobID, fileID string) {
|
||||
ctx := context.Background()
|
||||
if err := o.Repository.OcrJobs.TouchProcessing(deviceID, jobID); err != nil {
|
||||
// Run fait tourner le worker OCR : au démarrage il requeue les jobs laissés
|
||||
// en `processing` (crash), puis `workers` boucles consomment la file FIFO via
|
||||
// ClaimNext (atomique, `FOR UPDATE SKIP LOCKED`). `workers` borne la
|
||||
// concurrence Tesseract. Retourne quand ctx est annulé.
|
||||
func (o *Ocr) Run(ctx context.Context, workers int) {
|
||||
if workers < 1 {
|
||||
workers = 1
|
||||
}
|
||||
if _, err := o.Repository.OcrJobs.ResetStaleProcessing(o.StaleAfter); err != nil {
|
||||
log.Printf("ocr: requeue jobs stale: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
o.workerLoop(ctx)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// workerLoop consomme la file : un job `queued` → processing → done/failed.
|
||||
// File vide → scrutation à QueuePollInterval jusqu'à annulation du ctx.
|
||||
func (o *Ocr) workerLoop(ctx context.Context) {
|
||||
for {
|
||||
row, err := o.Repository.OcrJobs.ClaimNext()
|
||||
if err != nil {
|
||||
if !errors.Is(err, repository.ErrNoQueuedJobs) {
|
||||
log.Printf("ocr: claim: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(QueuePollInterval):
|
||||
}
|
||||
continue
|
||||
}
|
||||
o.process(ctx, row)
|
||||
}
|
||||
}
|
||||
|
||||
// process exécute l'extraction pour un job déjà réclamé (`processing`).
|
||||
func (o *Ocr) process(ctx context.Context, row repository.OcrJobRow) {
|
||||
userID, err := o.Repository.Resources.GetResourceOwner(row.FileID)
|
||||
if err != nil {
|
||||
_ = o.Repository.OcrJobs.Fail(row.DeviceID, row.ID, "file not readable")
|
||||
return
|
||||
}
|
||||
path, err := o.physicalPath(userID, fileID)
|
||||
path, err := o.physicalPath(userID, row.FileID)
|
||||
if err != nil {
|
||||
_ = o.Repository.OcrJobs.Fail(deviceID, jobID, "file not readable")
|
||||
_ = o.Repository.OcrJobs.Fail(row.DeviceID, row.ID, "file not readable")
|
||||
return
|
||||
}
|
||||
text, err := o.Engine.ExtractText(ctx, path, o.Lang)
|
||||
if err != nil {
|
||||
_ = o.Repository.OcrJobs.Fail(deviceID, jobID, err.Error())
|
||||
_ = o.Repository.OcrJobs.Fail(row.DeviceID, row.ID, err.Error())
|
||||
return
|
||||
}
|
||||
_ = o.Repository.OcrJobs.Complete(deviceID, jobID, text)
|
||||
_ = o.Repository.OcrJobs.Complete(row.DeviceID, row.ID, text)
|
||||
}
|
||||
|
||||
// physicalPath résout UPLOAD_DIR/<user_id>/<resource_id>.<ext> — l'ext est
|
||||
@@ -95,4 +153,4 @@ func toOcrJobDTO(row repository.OcrJobRow) OcrJobDTO {
|
||||
dto.Error = *row.Error
|
||||
}
|
||||
return dto
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,18 @@ func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string,
|
||||
return s.text, nil
|
||||
}
|
||||
|
||||
// newTestOcr builds an Ocr over a fresh DB and returns it with the ids.
|
||||
// newTestOcr builds an Ocr over a fresh DB, starts the bounded worker (1
|
||||
// worker) and returns it with the ids.
|
||||
func newTestOcr(t *testing.T, uploadDir string, engine ocr.Engine) (*Ocr, string, string, string) {
|
||||
t.Helper()
|
||||
s := newServiceStore(t)
|
||||
userID := mustCreateUser(t, s.Repository, "ocr-failed")
|
||||
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
|
||||
return NewOcr(s.Repository, uploadDir, "fra+eng", engine), userID, deviceID, uploadDir
|
||||
o := NewOcr(s.Repository, uploadDir, "fra+eng", engine)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go o.Run(ctx, 1)
|
||||
t.Cleanup(cancel)
|
||||
return o, userID, deviceID, uploadDir
|
||||
}
|
||||
|
||||
// waitTillTerminal poll jusqu'à un statut terminal (done/failed).
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
|
||||
|
||||
## 5. OCR
|
||||
|
||||
- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone** (goroutine par job côté serveur, V1).
|
||||
- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone** par une **file serveur bornée** (`ocr_jobs` + worker `Ocr.Run`, `OCR_WORKERS` défaut 2) — le handler n'enfile **jamais** de traitement, Tesseract tourne hors request handler (FIFO, récupération des `processing` orphelins au boot).
|
||||
- `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 user → `NOT_FOUND`. Fichier physique introuvable (ex. suppression manuelle sous `UPLOAD_DIR`) → job `failed` `"file not readable"`. Le job est créé par le device courant (`ocr_jobs.device_id`) mais la validation de la ressource est scopée par le **user**.
|
||||
|
||||
@@ -20,14 +20,16 @@ import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
|
||||
* v5: created_in_app sur folders ; v6: processed sur files (mode review) ;
|
||||
* v7: pending_operations (outbox) ; v8: scan_sessions + scan_pages (scanner) ;
|
||||
* v9: content sur files (corps des notes créées dans l'app) ;
|
||||
* v10: ocr_text sur files (extrait OCR serveur persisté localement).
|
||||
* v10: ocr_text sur files (extrait OCR serveur persisté localement) ;
|
||||
* v11: ocr_attempts + ocr_queued_at sur files (driver OCR auto côté client —
|
||||
* compteur d'échecs terminaux ≤ 3, marqueur « en cours » de l'upload).
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
FolderEntity::class, UserPreferenceEntity::class, FileEntity::class,
|
||||
PendingOperationEntity::class, ScanSessionEntity::class, ScanPageEntity::class,
|
||||
],
|
||||
version = 10,
|
||||
version = 11,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
@@ -126,4 +126,33 @@ interface FileDao {
|
||||
/** Persiste l'extrait OCR d'un fichier (résultat local du job serveur). */
|
||||
@Query("UPDATE files SET ocr_text = :text, updated_at = :updatedAt WHERE resource_id = :resourceId")
|
||||
suspend fun updateOcrText(resourceId: String, text: String?, updatedAt: Long)
|
||||
|
||||
/** Candidate OCR auto : fichier `processed`, `local-cloud`, avec copie
|
||||
* locale, format potentiellement extractible, jamais extrait, pas en cours
|
||||
* et pas épuisé (< 3 échecs terminaux). Trié par dernier upload. */
|
||||
@Query("""
|
||||
SELECT * FROM files
|
||||
WHERE "exists" = 1
|
||||
AND processed = 1
|
||||
AND sync_status = 'local-cloud'
|
||||
AND uri IS NOT NULL
|
||||
AND ocr_text IS NULL
|
||||
AND ocr_attempts < 3
|
||||
AND ocr_queued_at IS NULL
|
||||
AND category IN ('PDF', 'IMAGE', 'TEXT')
|
||||
ORDER BY COALESCE(last_modified, added_at) DESC, name ASC
|
||||
""")
|
||||
suspend fun getOcrAutoCandidates(): List<FileEntity>
|
||||
|
||||
/** Pose le marqueur « en cours » du driver OCR auto (upload en vol). */
|
||||
@Query("UPDATE files SET ocr_queued_at = :queuedAt, updated_at = :now WHERE resource_id = :resourceId")
|
||||
suspend fun markOcrQueued(resourceId: String, queuedAt: Long, now: Long)
|
||||
|
||||
/** Levé du marqueur + reset du compteur d'échecs (succès / erreur transitoire). */
|
||||
@Query("UPDATE files SET ocr_queued_at = NULL, ocr_attempts = 0, updated_at = :now WHERE resource_id = :resourceId")
|
||||
suspend fun clearOcrQueued(resourceId: String, now: Long)
|
||||
|
||||
/** Échec terminal : +1 tentative, marqueur levé (ne rejoue pas à l'infini). */
|
||||
@Query("UPDATE files SET ocr_attempts = ocr_attempts + 1, ocr_queued_at = NULL, updated_at = :now WHERE resource_id = :resourceId")
|
||||
suspend fun markOcrFailure(resourceId: String, now: Long)
|
||||
}
|
||||
@@ -57,6 +57,10 @@ data class FileEntity(
|
||||
val processed: Boolean = false,
|
||||
@ColumnInfo(name = "ocr_text")
|
||||
val ocrText: String? = null,
|
||||
@ColumnInfo(name = "ocr_attempts")
|
||||
val ocrAttempts: Int = 0,
|
||||
@ColumnInfo(name = "ocr_queued_at")
|
||||
val ocrQueuedAt: Long? = null,
|
||||
@ColumnInfo(name = "added_at")
|
||||
val addedAt: Long,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
|
||||
+8
-1
@@ -174,7 +174,14 @@ object Migrations {
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_10_11 = object : Migration(10, 11) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `files` ADD COLUMN `ocr_attempts` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE `files` ADD COLUMN `ocr_queued_at` INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: Array<Migration> = arrayOf(
|
||||
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10,
|
||||
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11,
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.core.net.toUri
|
||||
import com.vaultdrop.mobile.data.local.dao.FileDao
|
||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||
import com.vaultdrop.mobile.data.remote.ApiException
|
||||
import com.vaultdrop.mobile.data.remote.dto.OcrJobDto
|
||||
import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -74,11 +75,71 @@ class OcrRepository @Inject constructor(
|
||||
fileDao.updateOcrText(resourceId, text?.takeIf { it.isNotBlank() }, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR auto d'un fichier (un seul à la fois) : marqueur « en cours », upload
|
||||
* multipart ciblé, soumission du job serveur, poll jusqu'au terminal puis
|
||||
* persistance. Le worker [OcrAutoWorker] appelle cette méthode fichier par
|
||||
* fichier pour rester séquentiel — le traitement lourd reste la file
|
||||
* bornée côté serveur.
|
||||
*/
|
||||
suspend fun processAuto(file: FileEntity): OcrAutoOutcome {
|
||||
val now = System.currentTimeMillis()
|
||||
fileDao.markOcrQueued(file.resourceId, now, now)
|
||||
return try {
|
||||
ensurePhysical(file)
|
||||
val jobId = submit(file.resourceId)
|
||||
val final = poll(jobId)
|
||||
when (final.status) {
|
||||
OcrJobStatus.DONE -> {
|
||||
saveResult(file.resourceId, final.text)
|
||||
fileDao.clearOcrQueued(file.resourceId, System.currentTimeMillis())
|
||||
OcrAutoOutcome.Done
|
||||
}
|
||||
else -> {
|
||||
fileDao.markOcrFailure(file.resourceId, System.currentTimeMillis())
|
||||
OcrAutoOutcome.Failed
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
when {
|
||||
e.code == "NETWORK_ERROR" || e.httpCode >= 500 -> {
|
||||
// Transitoire : levé le marqueur, WorkManager retente avec backoff.
|
||||
fileDao.clearOcrQueued(file.resourceId, System.currentTimeMillis())
|
||||
OcrAutoOutcome.Retryable
|
||||
}
|
||||
e.httpCode == 401 -> OcrAutoOutcome.Unauthorized
|
||||
else -> {
|
||||
// Erreur permanente : +1 tentative (≤ 3), relance manuelle possible.
|
||||
fileDao.markOcrFailure(file.resourceId, System.currentTimeMillis())
|
||||
OcrAutoOutcome.Failed
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
fileDao.markOcrFailure(file.resourceId, System.currentTimeMillis())
|
||||
OcrAutoOutcome.Failed
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val POLL_INTERVAL_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
||||
/** Résultat d'une passe d'OCR auto (un fichier max). */
|
||||
sealed interface OcrAutoOutcome {
|
||||
/** Traité avec succès : `ocr_text` persisté. */
|
||||
data object Done : OcrAutoOutcome
|
||||
|
||||
/** Job terminal `failed` / erreur permanente : `ocr_attempts` incrémenté. */
|
||||
data object Failed : OcrAutoOutcome
|
||||
|
||||
/** Erreur transitoire : à retenter via backoff WorkManager. */
|
||||
data object Retryable : OcrAutoOutcome
|
||||
|
||||
/** Token expiré/révoqué : le re-login passera par l'UI. */
|
||||
data object Unauthorized : OcrAutoOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* `RequestBody` paresseux : le flux SAF est ouvert à l'écriture (multipart),
|
||||
* jamais chargé en mémoire — les fichiers jusqu'à `MAX_FILE_SIZE_MB` restent
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vaultdrop.mobile.features.ocr
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vaultdrop.mobile.auth.TokenProvider
|
||||
import com.vaultdrop.mobile.data.local.dao.FileDao
|
||||
import com.vaultdrop.mobile.data.remote.ApiException
|
||||
import com.vaultdrop.mobile.data.remote.dto.OcrJobStatus
|
||||
import com.vaultdrop.mobile.data.repository.OcrAutoOutcome
|
||||
import com.vaultdrop.mobile.data.repository.OcrRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* OCR auto fichier par fichier : à chaque exécution, un seul candidat
|
||||
* (`processed`, `local-cloud`, `ocr_text` nul, URI SAF présente,
|
||||
* `ocr_attempts < 3`) est uploadé puis enfilé (`POST /ocr/jobs`) dans la
|
||||
* **file bornée serveur** (`Ocr.Run`). Un seul run à la fois via
|
||||
* `enqueueUniqueWork(KEEP)`. Le worker se re-enchaine tant qu'il reste des
|
||||
* candidats *distincts* du dernier fichier traité (pas de relance immédiate
|
||||
* d'un fichier en échec, pour respecter le cap de 3 tentatives).
|
||||
*
|
||||
* Erreurs :
|
||||
* - **réseau / 5xx** → `Result.retry()` (backoff 30 s) sans incrémenter
|
||||
* `ocr_attempts` (le marqueur in-flight est levé) ;
|
||||
* - **4xx non-idempotente** → `ocr_attempts` incrémenté, pas de re-enchaine ;
|
||||
* - **token expiré (401)** → `Result.success()` (re-login passera par l'UI).
|
||||
*/
|
||||
@HiltWorker
|
||||
class OcrAutoWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val ocrRepository: OcrRepository,
|
||||
private val fileDao: FileDao,
|
||||
private val tokenProvider: TokenProvider,
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
if (tokenProvider.current == null) return Result.success()
|
||||
|
||||
val file = fileDao.getOcrAutoCandidates().firstOrNull()
|
||||
?: return Result.success()
|
||||
|
||||
Timber.d("ocr-auto: candidate %s (%s)", file.name, file.resourceId)
|
||||
val outcome = try {
|
||||
ocrRepository.processAuto(file)
|
||||
} catch (e: ApiException) {
|
||||
when {
|
||||
e.code == "NETWORK_ERROR" || e.httpCode >= 500 -> OcrAutoOutcome.Retryable
|
||||
e.httpCode == 401 -> OcrAutoOutcome.Unauthorized
|
||||
else -> OcrAutoOutcome.Failed
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "ocr-auto: unexpected failure for %s", file.resourceId)
|
||||
OcrAutoOutcome.Failed
|
||||
}
|
||||
|
||||
when (outcome) {
|
||||
is OcrAutoOutcome.Done ->
|
||||
Timber.d("ocr-auto: done %s", file.resourceId)
|
||||
is OcrAutoOutcome.Failed ->
|
||||
Timber.w("ocr-auto: failed %s", file.resourceId)
|
||||
is OcrAutoOutcome.Retryable -> return Result.retry()
|
||||
is OcrAutoOutcome.Unauthorized -> return Result.success()
|
||||
}
|
||||
|
||||
// Self-chain : un autre candidat *distinct* du dernier attend ?
|
||||
val more = fileDao.getOcrAutoCandidates().any { it.resourceId != file.resourceId }
|
||||
if (more) {
|
||||
enqueue(applicationContext)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "ocr_auto"
|
||||
|
||||
fun enqueue(context: Context) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<OcrAutoWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(NAME, ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -22,6 +22,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOperationType
|
||||
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||
import com.vaultdrop.mobile.data.remote.ApiException
|
||||
import com.vaultdrop.mobile.data.remote.dto.SyncOpDto
|
||||
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -62,6 +63,9 @@ class OutboxSyncWorker @AssistedInject constructor(
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java),
|
||||
)
|
||||
|
||||
/** Set à true si un fichier a été promu pendant ce run → enchaîne OcrAutoWorker. */
|
||||
private var filePromoted = false
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// Mode local : sans compte connecté, rien à pousser.
|
||||
if (tokenProvider.current == null) return Result.success()
|
||||
@@ -71,10 +75,13 @@ class OutboxSyncWorker @AssistedInject constructor(
|
||||
fileDao.backfillSyncedStatus(System.currentTimeMillis())
|
||||
folderDao.backfillSyncedStatus(System.currentTimeMillis())
|
||||
|
||||
filePromoted = false
|
||||
|
||||
while (true) {
|
||||
val pending = pendingOperationDao.selectPending(BATCH_SIZE)
|
||||
if (pending.isEmpty()) {
|
||||
pendingOperationDao.purgeSynced(System.currentTimeMillis() - PURGE_AGE_MS)
|
||||
if (filePromoted) OcrAutoWorker.enqueue(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@@ -139,7 +146,10 @@ class OutboxSyncWorker @AssistedInject constructor(
|
||||
PendingOperationType.CREATE_RESOURCE,
|
||||
PendingOperationType.MOVE_RESOURCE -> {
|
||||
when (op.resourceType) {
|
||||
"file" -> fileDao.promoteSyncStatus(resourceId, System.currentTimeMillis())
|
||||
"file" -> {
|
||||
fileDao.promoteSyncStatus(resourceId, System.currentTimeMillis())
|
||||
filePromoted = true
|
||||
}
|
||||
"folder" -> folderDao.promoteSyncStatus(resourceId, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vaultdrop.mobile.data.local.entity.PendingOperationEntity
|
||||
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||
import com.vaultdrop.mobile.data.repository.ShareRepository
|
||||
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -107,6 +108,8 @@ class SyncViewModel @Inject constructor(
|
||||
// Les nouvelles ressources découvertes sont dans l'outbox
|
||||
// → drainer vers POST /sync/ops (single-flight via KEEP).
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
// OCR auto : uploader les octets des fichiers récemment promus (local-cloud).
|
||||
OcrAutoWorker.enqueue(appContext)
|
||||
}
|
||||
.onFailure { e -> Timber.w(e, "syncAll failed, retrying later") }
|
||||
// Hydrate les ressources partagées depuis le snapshot serveur.
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.vaultdrop.mobile.data.remote.ApiException
|
||||
import com.vaultdrop.mobile.data.repository.AuthRepository
|
||||
import com.vaultdrop.mobile.data.repository.ShareRepository
|
||||
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
|
||||
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -51,6 +52,7 @@ class AuthViewModel @Inject constructor(
|
||||
authRepository.registerDevice()
|
||||
// Session restaurée → drainer l'outbox laissée en attente.
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
OcrAutoWorker.enqueue(appContext)
|
||||
if (session != null) {
|
||||
// Snapshot complet des permissions partagées (convergence).
|
||||
runCatching { shareRepository.syncSnapshot() }
|
||||
@@ -73,6 +75,7 @@ class AuthViewModel @Inject constructor(
|
||||
_authState.value = AuthState.SignedIn(response.user)
|
||||
// Connexion réussie → pousser les mutations locales en attente.
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
OcrAutoWorker.enqueue(appContext)
|
||||
// Snapshot complet des permissions partagées (convergence).
|
||||
runCatching { shareRepository.syncSnapshot() }
|
||||
.onFailure { e -> Timber.d("syncSnapshot on login failed: %s", e.message) }
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.vaultdrop.mobile.data.remote.ApiClient
|
||||
import com.vaultdrop.mobile.data.remote.ApiException
|
||||
import com.vaultdrop.mobile.data.repository.OutboxRepository
|
||||
import com.vaultdrop.mobile.features.sync.OutboxSyncWorker
|
||||
import com.vaultdrop.mobile.features.ocr.OcrAutoWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -68,6 +69,7 @@ class ShareViewModel @Inject constructor(
|
||||
)
|
||||
}.onSuccess {
|
||||
OutboxSyncWorker.enqueue(appContext)
|
||||
OcrAutoWorker.enqueue(appContext)
|
||||
Timber.d("share: op enqueued for %s", resourceId)
|
||||
_uiState.update { it.copy(sharing = false, enqueued = true) }
|
||||
}.onFailure { e ->
|
||||
|
||||
+87
-5
@@ -30,6 +30,7 @@ import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -117,18 +118,24 @@ class OcrRepositoryTest {
|
||||
assertEquals("boom", final.error)
|
||||
}
|
||||
|
||||
private fun insertFile(uri: String): FileEntity {
|
||||
private fun insertFile(
|
||||
uri: String?,
|
||||
ocrText: String? = null,
|
||||
category: String? = "TEXT",
|
||||
): FileEntity {
|
||||
val file = FileEntity(
|
||||
resourceId = "aabbccddeeff11223344556677889900",
|
||||
resourceId = generateResourceId(),
|
||||
uri = uri,
|
||||
name = "sample.txt",
|
||||
name = uri?.substringAfterLast('/') ?: "cloud.txt",
|
||||
folderResourceId = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0",
|
||||
extension = "txt",
|
||||
size = 13L,
|
||||
extension = uri?.substringAfterLast('.')?.take(8),
|
||||
size = 10L,
|
||||
mimeType = "text/plain",
|
||||
exists = 1,
|
||||
category = category,
|
||||
syncStatus = FileStatus.LOCAL_CLOUD,
|
||||
processed = true,
|
||||
ocrText = ocrText,
|
||||
addedAt = 1_700_000_000_000L,
|
||||
updatedAt = 1_700_000_000_000L,
|
||||
)
|
||||
@@ -136,8 +143,83 @@ class OcrRepositoryTest {
|
||||
return file
|
||||
}
|
||||
|
||||
private var idSeq = 0L
|
||||
private fun generateResourceId(): String {
|
||||
val hex = (++idSeq).toString(16).padStart(32, '0')
|
||||
return hex
|
||||
}
|
||||
|
||||
private fun moshi(): Moshi =
|
||||
Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||
|
||||
@Test
|
||||
fun `getOcrAutoCandidates_exclut_ocr_text_present`() = runTest {
|
||||
val withText = insertFile("content://ocr/a.txt", ocrText = "extrait")
|
||||
val withoutText = insertFile("content://ocr/b.txt", ocrText = null)
|
||||
|
||||
val candidates = fileDao.getOcrAutoCandidates()
|
||||
|
||||
assertEquals(1, candidates.size)
|
||||
assertEquals(withoutText.resourceId, candidates[0].resourceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOcrAutoCandidates_exclut_uri_absente_et_cloud_only`() = runTest {
|
||||
insertFile("content://ocr/a.txt", ocrText = null)
|
||||
insertFile(null, ocrText = null)
|
||||
|
||||
val candidates = fileDao.getOcrAutoCandidates()
|
||||
|
||||
assertEquals(1, candidates.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOcrAutoCandidates_exclut_exhausted_attempts_3`() = runTest {
|
||||
val exhausted = insertFile("content://ocr/a.txt", ocrText = null)
|
||||
fileDao.markOcrFailure(exhausted.resourceId, System.currentTimeMillis())
|
||||
fileDao.markOcrFailure(exhausted.resourceId, System.currentTimeMillis())
|
||||
fileDao.markOcrFailure(exhausted.resourceId, System.currentTimeMillis())
|
||||
|
||||
assertTrue(fileDao.getOcrAutoCandidates().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processAuto_done_persiste_texte_et_clear_queued`() = runTest {
|
||||
val uri = "content://ocr/auto-done.txt"
|
||||
shadowOf(context.contentResolver).registerInputStream(uri.toUri(), ByteArrayInputStream("auto".toByteArray()))
|
||||
|
||||
val file = insertFile(uri)
|
||||
apiService.jobSequence = listOf(
|
||||
OcrJobDto(id = "auto-1", status = OcrJobStatus.QUEUED),
|
||||
OcrJobDto(id = "auto-1", status = OcrJobStatus.DONE, text = "autoOCR"),
|
||||
)
|
||||
|
||||
val outcome = repository.processAuto(file)
|
||||
|
||||
assertTrue(outcome is OcrAutoOutcome.Done)
|
||||
val stored = fileDao.getByResourceId(file.resourceId)
|
||||
assertEquals("autoOCR", stored?.ocrText)
|
||||
assertEquals(0, stored?.ocrAttempts)
|
||||
assertNull(stored?.ocrQueuedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processAuto_failed_incremente_attempts_et_clear_queued`() = runTest {
|
||||
val uri = "content://ocr/auto-fail.txt"
|
||||
shadowOf(context.contentResolver).registerInputStream(uri.toUri(), ByteArrayInputStream(byteArrayOf()))
|
||||
val file = insertFile(uri)
|
||||
apiService.jobSequence = listOf(
|
||||
OcrJobDto(id = "auto-f", status = OcrJobStatus.FAILED, error = "tess err"),
|
||||
)
|
||||
|
||||
val outcome = repository.processAuto(file)
|
||||
|
||||
assertTrue(outcome is OcrAutoOutcome.Failed)
|
||||
val stored = fileDao.getByResourceId(file.resourceId)
|
||||
assertEquals(1, stored?.ocrAttempts)
|
||||
assertNull(stored?.ocrQueuedAt)
|
||||
assertNull(stored?.ocrText)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user