Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
161 lines
6.4 KiB
Go
161 lines
6.4 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
// OCRWord is one word-level bounding box persisted for a document, mirroring
|
|
// internal/ocr.WordBox (Phase 1 of the OCR text-highlight/overlay feature —
|
|
// see project memory project_ocr_textmarkierung_overlay.md). Coordinates are
|
|
// already in the original, undoctored file's coordinate space (see
|
|
// internal/ocr/coords.go package doc comment), not tesseract's
|
|
// post-preprocessing space.
|
|
type OCRWord struct {
|
|
ID int64
|
|
DocumentID int64
|
|
Page int
|
|
Block int
|
|
Par int
|
|
Line int
|
|
Word string
|
|
Left int
|
|
Top int
|
|
Width int
|
|
Height int
|
|
Confidence float64
|
|
}
|
|
|
|
// initOCRWordsSchema creates the ocr_words table (see
|
|
// migrations/024_ocr_words.sql). Wired into Store.initSchema after documents
|
|
// exists (FK ON DELETE CASCADE — word boxes have no independent GoBD
|
|
// retention meaning of their own; they are a derived index over a document's
|
|
// ocr_text/original file, not a document themselves, so cascading their
|
|
// deletion when the parent document row is hard-deleted from the trash
|
|
// workflow is the correct WORM behaviour here, unlike documents.storage_path
|
|
// or content_hash which must never be touched).
|
|
//
|
|
// No tenant_id column: access is always mediated through document_id, and
|
|
// every caller (bulk insert, delete-before-reprocess, and the future read
|
|
// endpoint in Phase 3) must join/verify against documents(tenant_id) rather
|
|
// than filter ocr_words directly, so there is no tenant-scan risk from
|
|
// omitting it — but see the README entry for this migration if that
|
|
// assumption ever changes.
|
|
func (s *Store) initOCRWordsSchema(ctx context.Context) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS ocr_words (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
page INTEGER NOT NULL DEFAULT 1,
|
|
block INTEGER NOT NULL DEFAULT 0,
|
|
par INTEGER NOT NULL DEFAULT 0,
|
|
line INTEGER NOT NULL DEFAULT 0,
|
|
word_text TEXT NOT NULL,
|
|
"left" INTEGER NOT NULL,
|
|
top INTEGER NOT NULL,
|
|
width INTEGER NOT NULL,
|
|
height INTEGER NOT NULL,
|
|
confidence DOUBLE PRECISION NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ocr_words_document ON ocr_words (document_id);
|
|
-- Plain btree on word_text: cheap prefilter for an eventual highlight
|
|
-- feature doing exact/prefix matching against search terms already
|
|
-- tokenized elsewhere. Deliberately NOT a pg_trgm GIN index — pg_trgm
|
|
-- requires "CREATE EXTENSION pg_trgm", which needs DB-superuser
|
|
-- privileges initSchema cannot assume it has on 192.168.1.204 (see
|
|
-- other Store init* methods: none of them create extensions). If
|
|
-- fuzzy/substring prefiltering turns out to be needed for the Phase 3
|
|
-- read endpoint, add the extension out-of-band on the server first,
|
|
-- then switch this index.
|
|
CREATE INDEX IF NOT EXISTS idx_ocr_words_word_text ON ocr_words (word_text);
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: create ocr_words table: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReplaceOCRWords atomically deletes any previously stored word boxes for a
|
|
// document and bulk-inserts the new set, so reprocess/re-OCR runs never pile
|
|
// up duplicate word rows alongside stale ones. A nil/empty words slice is
|
|
// valid (e.g. OCR found no words, or Result.Words extraction failed
|
|
// best-effort) and simply leaves the document with zero rows.
|
|
func (s *Store) ReplaceOCRWords(ctx context.Context, documentID int64, words []OCRWord) error {
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: begin replace ocr_words: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM ocr_words WHERE document_id = $1`, documentID); err != nil {
|
|
return fmt.Errorf("storage: delete ocr_words: %w", err)
|
|
}
|
|
|
|
for _, w := range words {
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO ocr_words
|
|
(document_id, page, block, par, line, word_text, "left", top, width, height, confidence)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
`, documentID, w.Page, w.Block, w.Par, w.Line, w.Word, w.Left, w.Top, w.Width, w.Height, w.Confidence)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: insert ocr_word: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("storage: commit replace ocr_words: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListOCRWords returns all persisted word boxes of a document in natural
|
|
// reading order (page, block, par, line, then insertion order via id), which is
|
|
// the order the overlay renderer needs to group words back into lines.
|
|
//
|
|
// NOTE (tenant isolation): ocr_words has no tenant_id column by design (see
|
|
// initOCRWordsSchema), so this function is deliberately NOT tenant-scoped. Every
|
|
// caller MUST have verified document ownership beforehand — the API handler
|
|
// does so via GetDocument(id, tenantID), exactly like the audit/notes/file
|
|
// sub-routes.
|
|
//
|
|
// Always returns a non-nil slice so JSON encoding yields [] rather than null.
|
|
func (s *Store) ListOCRWords(ctx context.Context, documentID int64) ([]OCRWord, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, document_id, page, block, par, line, word_text, "left", top, width, height, confidence
|
|
FROM ocr_words
|
|
WHERE document_id = $1
|
|
ORDER BY page, block, par, line, id
|
|
`, documentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list ocr_words: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]OCRWord, 0)
|
|
for rows.Next() {
|
|
var w OCRWord
|
|
if err := rows.Scan(&w.ID, &w.DocumentID, &w.Page, &w.Block, &w.Par, &w.Line, &w.Word,
|
|
&w.Left, &w.Top, &w.Width, &w.Height, &w.Confidence); err != nil {
|
|
return nil, fmt.Errorf("storage: scan ocr_word: %w", err)
|
|
}
|
|
out = append(out, w)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("storage: iterate ocr_words: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteOCRWords removes all stored word boxes for a document. Exposed
|
|
// separately from ReplaceOCRWords for callers (e.g. a future hard-delete
|
|
// path) that need to clear word boxes without immediately re-inserting new
|
|
// ones; ON DELETE CASCADE already handles the case where the documents row
|
|
// itself is removed, so this is only needed when the document row survives
|
|
// but its word boxes must be cleared independently.
|
|
func (s *Store) DeleteOCRWords(ctx context.Context, documentID int64) error {
|
|
if _, err := s.db.Exec(ctx, `DELETE FROM ocr_words WHERE document_id = $1`, documentID); err != nil {
|
|
return fmt.Errorf("storage: delete ocr_words: %w", err)
|
|
}
|
|
return nil
|
|
}
|