FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user