feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,66 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// minFreeBytes ist die Untergrenze, unter der die Texterkennung pausiert.
|
||||
//
|
||||
// OCR ist das einzige Stück dieser Anwendung, das von sich aus und ohne
|
||||
// Zutun eines Menschen Platz verbraucht: Es arbeitet eine Warteschlange ab,
|
||||
// solange etwas darin liegt. Eine Cloud, die ihre eigene Platte vollschreibt,
|
||||
// nimmt Uploads nicht mehr an – und das wäre dann kein OCR-Problem mehr,
|
||||
// sondern ein Ausfall.
|
||||
//
|
||||
// 2 GiB ist mit Absicht großzügig: Der Sidecar einer Erkennung ist kaum größer
|
||||
// als ein Megabyte, tesseract und ghostscript brauchen währenddessen aber
|
||||
// Arbeitsdateien, deren Größe von der Auflösung der Vorlage abhängt. Lieber
|
||||
// eine Weile nichts erkennen als eine volle Platte.
|
||||
const minFreeBytes = 2 << 30 // 2 GiB
|
||||
|
||||
// freeBytes meldet den freien Platz auf dem Dateisystem, auf dem path liegt.
|
||||
func freeBytes(path string) (uint64, error) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return 0, fmt.Errorf("statfs %s: %w", path, err)
|
||||
}
|
||||
// Bavail, nicht Bfree: Bfree zählt auch die Reserve mit, die nur root
|
||||
// benutzen darf – der Server läuft aber als unprivilegierter Nutzer und
|
||||
// käme daran nie heran.
|
||||
return st.Bavail * uint64(st.Bsize), nil
|
||||
}
|
||||
|
||||
// sweepTemp räumt Zwischendateien früherer Läufe weg.
|
||||
//
|
||||
// Im Normalfall räumt der Arbeiter selbst auf. Wird der Container aber hart
|
||||
// beendet – `docker kill`, ein Absturz, Stromausfall –, läuft kein defer mehr,
|
||||
// und die angefangene Datei bleibt liegen. Ohne diesen Durchgang sammelt sich
|
||||
// das über Monate an, ausgerechnet auf der Platte, die auch die Dateien trägt.
|
||||
//
|
||||
// Es wird nur weggeräumt, was diese Anwendung selbst angelegt hat: das Präfix
|
||||
// "ocr-" im Zwischenverzeichnis. Fremde Dateien bleiben unangetastet.
|
||||
func sweepTemp(dir string) (int, int64) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var count int
|
||||
var bytes int64
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "ocr-") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, e.Name())
|
||||
if info, err := e.Info(); err == nil {
|
||||
bytes += info.Size()
|
||||
}
|
||||
if err := os.Remove(full); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, bytes
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Package documents macht den Inhalt von Dokumenten durchsuchbar.
|
||||
//
|
||||
// Die Namenssuche (internal/files/search.go) findet "Rechnung_Stadtwerke.pdf".
|
||||
// Ein Scanner nennt seine Ablage aber "scan_0042.pdf" – der Name sagt dann
|
||||
// nichts, und der Beleg ist praktisch verloren. Dieses Paket holt den Text aus
|
||||
// der Datei und legt ihn nach file_text, wo die Suche ihn findet.
|
||||
package documents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Herkunft des Textes. Der Unterschied zählt bei der Fehlersuche: Findet die
|
||||
// Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen haben kann.
|
||||
const (
|
||||
SourceLayer = "layer" // aus der Textebene des PDFs – verlustfrei
|
||||
SourceOCR = "ocr" // aus dem Bild erkannt – kann Fehler enthalten
|
||||
)
|
||||
|
||||
// minCharsPerPage entscheidet, ob ein PDF schon Text mitbringt.
|
||||
//
|
||||
// Nicht "> 0 Zeichen": Fast jeder Scan trägt eine Kopfzeile, eine Seitenzahl
|
||||
// oder ein Wasserzeichen als echten Text – oft nur zehn Zeichen. Ohne diese
|
||||
// Schwelle gälte er als lesbar, und die eigentliche Rechnung bliebe
|
||||
// unauffindbar. Eine getippte Seite hat mehrere tausend Zeichen; 120 trennt die
|
||||
// beiden Fälle mit reichlich Abstand in beide Richtungen.
|
||||
const minCharsPerPage = 120
|
||||
|
||||
// maxTextBytes deckelt, was in die Datenbank geht. Ein 400-seitiges Handbuch
|
||||
// erzeugt sonst mehrere Megabyte Text pro Datei, und der GIN-Index wächst mit.
|
||||
// Für einen Beleg ist das weit jenseits von allem; abgeschnitten wird am Ende,
|
||||
// wo der Inhalt ohnehin am wenigsten aussagt.
|
||||
const maxTextBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// Extractor holt Text aus einer Datei. Er ruft externe Programme auf –
|
||||
// pdftotext, ocrmypdf, tesseract –, die im Container mitgeliefert werden.
|
||||
type Extractor struct {
|
||||
// Languages sind die Sprachmodelle für tesseract, z. B. "deu+eng".
|
||||
Languages string
|
||||
// MaxPages begrenzt, wie viele Seiten erkannt werden. OCR kostet Sekunden
|
||||
// je Seite; ohne Grenze blockiert ein einzelnes dickes PDF die
|
||||
// Warteschlange für alle anderen.
|
||||
MaxPages int
|
||||
// Timeout je Datei. Greift zusätzlich zu MaxPages, weil auch eine einzelne
|
||||
// Seite entgleisen kann.
|
||||
Timeout time.Duration
|
||||
// TmpDir nimmt die Zwischendateien auf. Leer heißt: das System entscheidet.
|
||||
TmpDir string
|
||||
}
|
||||
|
||||
// Result ist der gefundene Text mitsamt seiner Herkunft.
|
||||
type Result struct {
|
||||
Text string
|
||||
Source string
|
||||
// Lang sind die verwendeten Sprachmodelle – bei SourceLayer leer, weil
|
||||
// dort nichts erkannt, sondern nur ausgelesen wurde.
|
||||
Lang string
|
||||
Pages int
|
||||
// Chars ist die Länge *vor* dem Kürzen auf maxTextBytes.
|
||||
Chars int
|
||||
}
|
||||
|
||||
// Supported sagt, ob aus dieser Datei überhaupt Text zu holen ist.
|
||||
//
|
||||
// Bewusst eng: PDFs und Bilder. Word- und Excel-Dateien bräuchten je einen
|
||||
// eigenen Umwandler; sie tragen ihren Text ohnehin meist schon im Namen und
|
||||
// sind selten das, was eingescannt wird.
|
||||
func Supported(mime, name string) bool {
|
||||
m := strings.ToLower(mime)
|
||||
if strings.Contains(m, "pdf") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(m, "image/") {
|
||||
// SVG ist Text, kein Bild – tesseract kann damit nichts anfangen.
|
||||
return !strings.Contains(m, "svg")
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Available prüft, ob die Programme da sind. Fehlen sie, läuft die Cloud
|
||||
// weiter – nur ohne Volltext. Ein fehlendes Paket soll den Start nicht
|
||||
// verhindern.
|
||||
func Available() (bool, []string) {
|
||||
var missing []string
|
||||
for _, bin := range []string{"pdftotext", "ocrmypdf", "tesseract"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
missing = append(missing, bin)
|
||||
}
|
||||
}
|
||||
return len(missing) == 0, missing
|
||||
}
|
||||
|
||||
// Extract liest die Datei unter path. Das Original wird dabei **nicht**
|
||||
// angefasst.
|
||||
//
|
||||
// Das ist die wichtigste Eigenschaft hier: ocrmypdf schreibt normalerweise ein
|
||||
// neues PDF mit eingebetteter Textebene. Täten wir das, änderten sich Größe und
|
||||
// Prüfsumme der Datei – die gespeicherte Prüfsumme passte nicht mehr, die
|
||||
// Versionshistorie bekäme einen Eintrag, den niemand ausgelöst hat, und
|
||||
// WebDAV-Clients hielten die Datei für fremdverändert und lüden sie erneut. Für
|
||||
// ein Archiv ist eine unangetastete Datei mehr wert als ein durchsuchbares PDF.
|
||||
// Der Text landet deshalb daneben in der Datenbank, das PDF bleibt Byte für
|
||||
// Byte, wie es hochgeladen wurde.
|
||||
func (e *Extractor) Extract(ctx context.Context, path, mime, name string) (Result, error) {
|
||||
if e.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
isPDF := strings.Contains(strings.ToLower(mime), "pdf") ||
|
||||
strings.EqualFold(filepath.Ext(name), ".pdf")
|
||||
if isPDF {
|
||||
return e.extractPDF(ctx, path)
|
||||
}
|
||||
return e.extractImage(ctx, path)
|
||||
}
|
||||
|
||||
func (e *Extractor) extractPDF(ctx context.Context, path string) (Result, error) {
|
||||
pages := e.pageCount(ctx, path)
|
||||
|
||||
// Erst der billige Weg: Hat das PDF eine Textebene, ist pdftotext in
|
||||
// Millisekunden fertig und liefert den Text exakt so, wie er gesetzt wurde.
|
||||
// OCR wäre hier nicht nur langsamer, sondern schlechter – es würde
|
||||
// fehlerfreien Text durch geratenen ersetzen.
|
||||
limit := pages
|
||||
if e.MaxPages > 0 && (limit == 0 || limit > e.MaxPages) {
|
||||
limit = e.MaxPages
|
||||
}
|
||||
args := []string{"-q"}
|
||||
if limit > 0 {
|
||||
args = append(args, "-l", fmt.Sprint(limit))
|
||||
}
|
||||
args = append(args, path, "-")
|
||||
|
||||
layer, err := e.run(ctx, "pdftotext", args...)
|
||||
if err == nil {
|
||||
text := clean(layer)
|
||||
perPage := len(text)
|
||||
if pages > 1 {
|
||||
perPage = len(text) / pages
|
||||
}
|
||||
if perPage >= minCharsPerPage {
|
||||
return finish(text, SourceLayer, "", pages), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Zu wenig Text – also ein Scan. Jetzt OCR.
|
||||
sidecar, err := os.CreateTemp(e.TmpDir, "ocr-*.txt")
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sidecar anlegen: %w", err)
|
||||
}
|
||||
sidecarPath := sidecar.Name()
|
||||
sidecar.Close()
|
||||
defer os.Remove(sidecarPath)
|
||||
|
||||
ocrArgs := []string{
|
||||
"--quiet",
|
||||
"-l", e.Languages,
|
||||
// Der eigentliche Zweck des Aufrufs: --sidecar schreibt den erkannten
|
||||
// Text als reine Textdatei daneben.
|
||||
"--sidecar", sidecarPath,
|
||||
// Wir landen hier nur, wenn kaum Text da ist. --force-ocr rastert die
|
||||
// Seite und erkennt sie neu, statt an den paar vorhandenen Zeichen zu
|
||||
// scheitern (ocrmypdf bricht sonst mit PriorOcrFoundError ab).
|
||||
"--force-ocr",
|
||||
// Kein Ergebnis-PDF erzeugen. Uns interessiert allein der Sidecar; das
|
||||
// Schreiben (und Optimieren) einer Datei, die sofort wieder
|
||||
// verschwindet, wäre reine Rechenzeit und reiner Plattenplatz.
|
||||
"--output-type", "none",
|
||||
"--optimize", "0",
|
||||
}
|
||||
if e.MaxPages > 0 && pages > e.MaxPages {
|
||||
ocrArgs = append(ocrArgs, "--pages", fmt.Sprintf("1-%d", e.MaxPages))
|
||||
}
|
||||
// Ausgabe nach stdout, und dank --output-type none kommt dort nichts an:
|
||||
// gemessen 0 Bytes, Sidecar trotzdem vollständig. Damit entsteht gar kein
|
||||
// Ergebnis-PDF – wichtig auf einer Platte, die knapp werden kann, denn ein
|
||||
// force-OCR rastert jede Seite und das Ergebnis kann ein Vielfaches der
|
||||
// Vorlage wiegen.
|
||||
//
|
||||
// /dev/null als Ausgabe geht übrigens **nicht** – ocrmypdf bricht damit
|
||||
// mit Exit 3 ab. Der Bindestrich ist der Weg.
|
||||
ocrArgs = append(ocrArgs, path, "-")
|
||||
|
||||
if _, err := e.run(ctx, "ocrmypdf", ocrArgs...); err != nil {
|
||||
return Result{}, fmt.Errorf("ocrmypdf: %w", err)
|
||||
}
|
||||
raw, err := os.ReadFile(sidecarPath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("sidecar lesen: %w", err)
|
||||
}
|
||||
return finish(clean(string(raw)), SourceOCR, e.Languages, pages), nil
|
||||
}
|
||||
|
||||
func (e *Extractor) extractImage(ctx context.Context, path string) (Result, error) {
|
||||
out, err := e.run(ctx, "tesseract", path, "stdout", "-l", e.Languages)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("tesseract: %w", err)
|
||||
}
|
||||
return finish(clean(out), SourceOCR, e.Languages, 1), nil
|
||||
}
|
||||
|
||||
// pageCount fragt pdfinfo nach der Seitenzahl. Schlägt das fehl, arbeiten wir
|
||||
// mit 0 weiter – die Seitenzahl steuert nur Grenzen, sie ist nicht kritisch.
|
||||
func (e *Extractor) pageCount(ctx context.Context, path string) int {
|
||||
out, err := e.run(ctx, "pdfinfo", path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.HasPrefix(line, "Pages:") {
|
||||
continue
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(line), "Pages: %d", &n); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (e *Extractor) run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errOut.String())
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300] + "…"
|
||||
}
|
||||
if msg != "" {
|
||||
return "", fmt.Errorf("%s: %w (%s)", name, err, msg)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// clean räumt auf, was aus den Werkzeugen kommt.
|
||||
//
|
||||
// **Der Seitenumbruch (\f) bleibt stehen.** Für die Suche ist er bedeutungslos
|
||||
// – ein tsvector kennt keine Seiten –, für den Betrachter ist er das Einzige,
|
||||
// woran sich ablesen lässt, *wo* ein Wort steht. Bei einem Scan liegt im PDF
|
||||
// nur ein Bild; der erkannte Text steht ausschließlich hier. Ohne die Marke
|
||||
// beginnt der Betrachter bei einem zwölfseitigen Beleg wieder auf Seite 1.
|
||||
//
|
||||
// Ein Wust aus Leerzeilen bläht den Text dagegen nur auf. Ungültiges UTF-8
|
||||
// muss raus, weil Postgres es sonst zurückweist und der ganze Auftrag
|
||||
// scheitert; OCR liefert es bei verrauschten Vorlagen durchaus.
|
||||
func clean(s string) string {
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
if !utf8.ValidString(s) {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
blank := 0
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimRight(line, " \t\r")
|
||||
// Die Seitenmarke steht bei pdftotext am Ende der letzten Zeile einer
|
||||
// Seite. Sie überlebt das Trimmen und zählt nicht als leer.
|
||||
if strings.TrimSpace(strings.ReplaceAll(line, "\f", "")) == "" && !strings.Contains(line, "\f") {
|
||||
blank++
|
||||
if blank > 1 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
blank = 0
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// finish kürzt auf maxTextBytes und merkt sich die ursprüngliche Länge.
|
||||
func finish(text, source, lang string, pages int) Result {
|
||||
chars := utf8.RuneCountInString(text)
|
||||
if len(text) > maxTextBytes {
|
||||
text = text[:maxTextBytes]
|
||||
// Nicht mitten in einem Zeichen abschneiden – das ergäbe ungültiges
|
||||
// UTF-8, und Postgres lehnte den ganzen Text ab.
|
||||
for len(text) > 0 && !utf8.ValidString(text) {
|
||||
text = text[:len(text)-1]
|
||||
}
|
||||
}
|
||||
return Result{Text: text, Source: source, Lang: lang, Pages: pages, Chars: chars}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// Handler zeigt den Stand der Texterkennung.
|
||||
//
|
||||
// Ohne diese Auskunft ist nicht zu erkennen, ob der Volltext funktioniert:
|
||||
// Eine Suche, die nichts findet, sieht genauso aus wie eine Suche in einem
|
||||
// Bestand, der noch gar nicht erkannt wurde. Nach dem ersten Start läuft der
|
||||
// Arbeiter je nach Menge Stunden – ohne Zähler weiß niemand, ob er
|
||||
// vorankommt.
|
||||
type Handler struct {
|
||||
store *Store
|
||||
// isAdmin und userID trennen die Rechteprüfung von diesem Paket.
|
||||
// documents kennt weder Sessions noch Rollen, und soll es auch nicht.
|
||||
isAdmin func(r *http.Request) bool
|
||||
userID func(r *http.Request) uuid.UUID
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, isAdmin func(r *http.Request) bool, userID func(r *http.Request) uuid.UUID) *Handler {
|
||||
return &Handler{store: store, isAdmin: isAdmin, userID: userID}
|
||||
}
|
||||
|
||||
// Status beantwortet GET /api/v1/admin/ocr – der Gesamtstand des Servers.
|
||||
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.isAdmin(r) {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
// h.store nil heißt: OCR ist abgeschaltet oder die Programme fehlen. Das
|
||||
// ist eine Auskunft, kein Fehler – die Oberfläche soll „aus" anzeigen
|
||||
// können, statt in einen Fehlerzustand zu laufen.
|
||||
if h == nil || h.store == nil {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
return
|
||||
}
|
||||
stats, err := h.store.Stats(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "ocr status failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"pending": stats.Pending,
|
||||
"running": stats.Running,
|
||||
"done": stats.Done,
|
||||
"failed": stats.Failed,
|
||||
"indexed": stats.Indexed,
|
||||
})
|
||||
}
|
||||
|
||||
// MyStatus beantwortet GET /api/v1/ocr – der Stand der **eigenen** Dateien.
|
||||
//
|
||||
// Ohne diesen Endpunkt wüsste nur der Administrator, ob ein Dokument schon
|
||||
// gelesen wurde. Das ist die falsche Person: Wissen muss es, wer die Rechnung
|
||||
// hochgeladen hat und sie gerade nicht findet.
|
||||
func (h *Handler) MyStatus(w http.ResponseWriter, r *http.Request) {
|
||||
uid := h.userID(r)
|
||||
if uid == uuid.Nil {
|
||||
response.Error(w, http.StatusUnauthorized, "not signed in")
|
||||
return
|
||||
}
|
||||
if h == nil || h.store == nil {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
|
||||
return
|
||||
}
|
||||
stats, err := h.store.StatsForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "ocr status failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": true,
|
||||
"pending": stats.Pending,
|
||||
"running": stats.Running,
|
||||
"done": stats.Done,
|
||||
"failed": stats.Failed,
|
||||
"indexed": stats.Indexed,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ExtractVersion ist die Fassung der Textaufbereitung.
|
||||
//
|
||||
// Sie steht an jedem Eintrag in file_text. Wird die Aufbereitung geändert, so
|
||||
// dass alte Einträge nicht mehr taugen, wird die Zahl erhöht – beim Start
|
||||
// werden sie dann erneut erkannt, statt still nach der alten Regel liegen zu
|
||||
// bleiben. Ohne so eine Marke fällt der Unterschied nie auf: Der Text ist ja
|
||||
// da, er kann nur weniger.
|
||||
//
|
||||
// 1 – Seitenumbruch (\f) bleibt im Text stehen (23.08.2026)
|
||||
const ExtractVersion = 1
|
||||
|
||||
// ErrNoJob heißt: gerade nichts zu tun. Kein Fehler, sondern der Normalfall.
|
||||
var ErrNoJob = errors.New("no pending job")
|
||||
|
||||
// maxAttempts begrenzt, wie oft ein Auftrag wiederholt wird.
|
||||
//
|
||||
// Zwei Wiederholungen fangen das ab, was vorübergehend war – ein Neustart
|
||||
// mitten im Lauf, kurzzeitig fehlender Speicherplatz. Was dreimal scheitert,
|
||||
// scheitert an der Datei selbst (kaputtes PDF, passwortgeschützt), und ein
|
||||
// vierter Versuch verbrennt nur Rechenzeit. Der Auftrag bleibt dann als
|
||||
// 'failed' mit Fehlertext stehen, statt still zu verschwinden.
|
||||
const maxAttempts = 3
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Job ist ein Auftrag samt allem, was der Arbeiter über die Datei braucht.
|
||||
type Job struct {
|
||||
ID uuid.UUID
|
||||
FileID uuid.UUID
|
||||
Attempts int
|
||||
Name string
|
||||
MimeType string
|
||||
StorageKey string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Enqueue trägt eine Datei zur Texterkennung ein.
|
||||
//
|
||||
// ON CONFLICT statt eines zweiten Eintrags: Wird dieselbe Datei erneut
|
||||
// hochgeladen, ist der alte Text hinfällig – der Auftrag wird auf 'pending'
|
||||
// zurückgesetzt, statt sich daneben zu stapeln. Der Zähler geht dabei auf 0,
|
||||
// weil eine neue Fassung eine neue Chance verdient: Vielleicht war es genau
|
||||
// der kaputte Scan, der ersetzt wurde.
|
||||
func (s *Store) Enqueue(ctx context.Context, fileID uuid.UUID) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ocr_jobs (file_id, status)
|
||||
VALUES ($1, 'pending')
|
||||
ON CONFLICT (file_id) DO UPDATE
|
||||
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
|
||||
fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue ocr job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Claim holt den ältesten offenen Auftrag und markiert ihn als laufend.
|
||||
//
|
||||
// FOR UPDATE SKIP LOCKED ist der Kern: Zwei Arbeiter greifen damit nie
|
||||
// denselben Auftrag – der zweite überspringt die gesperrte Zeile, statt darauf
|
||||
// zu warten. Heute läuft nur einer, aber die Sperre kostet nichts und erspart
|
||||
// die Suche nach doppelt erkannten Dateien, sobald es zwei werden.
|
||||
//
|
||||
// 'running' wird mit übernommen, nicht nur 'pending': Ein Auftrag, der beim
|
||||
// Neustart mittendrin war, steht sonst für immer auf 'running' und wird nie
|
||||
// wieder angefasst. Der Zähler sorgt dafür, dass daraus keine Endlosschleife
|
||||
// wird, falls die Datei den Server reproduzierbar umbringt.
|
||||
func (s *Store) Claim(ctx context.Context) (*Job, error) {
|
||||
var j Job
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE ocr_jobs
|
||||
SET status = 'running', attempts = attempts + 1, updated_at = NOW()
|
||||
WHERE id = (
|
||||
SELECT j.id
|
||||
FROM ocr_jobs j
|
||||
JOIN files f ON f.id = j.file_id AND f.is_deleted = false
|
||||
WHERE j.status IN ('pending', 'running')
|
||||
AND j.attempts < $1
|
||||
ORDER BY j.created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1)
|
||||
RETURNING id, file_id, attempts,
|
||||
(SELECT name FROM files WHERE id = file_id),
|
||||
(SELECT mime_type FROM files WHERE id = file_id),
|
||||
(SELECT storage_key FROM files WHERE id = file_id),
|
||||
(SELECT size FROM files WHERE id = file_id)`,
|
||||
maxAttempts,
|
||||
).Scan(&j.ID, &j.FileID, &j.Attempts, &j.Name, &j.MimeType, &j.StorageKey, &j.Size)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNoJob
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim ocr job: %w", err)
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// Complete speichert den Text und schließt den Auftrag ab. Beides in einer
|
||||
// Transaktion: Ein gespeicherter Text ohne erledigten Auftrag würde beim
|
||||
// nächsten Durchgang erneut erkannt, ein erledigter Auftrag ohne Text ließe die
|
||||
// Datei unauffindbar zurück.
|
||||
func (s *Store) Complete(ctx context.Context, job *Job, res Result) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (file_id) DO UPDATE
|
||||
SET content = EXCLUDED.content, source = EXCLUDED.source,
|
||||
lang = EXCLUDED.lang, chars = EXCLUDED.chars,
|
||||
pages = EXCLUDED.pages, extract_version = EXCLUDED.extract_version,
|
||||
created_at = NOW()`,
|
||||
job.FileID, res.Text, res.Source, res.Lang, res.Chars, res.Pages, ExtractVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("save text: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE ocr_jobs SET status = 'done', last_error = '', updated_at = NOW() WHERE id = $1`,
|
||||
job.ID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("finish job: %w", err)
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Fail vermerkt den Fehler. Ist der Zähler ausgeschöpft, bleibt der Auftrag als
|
||||
// 'failed' liegen; sonst geht er zurück in die Schlange.
|
||||
func (s *Store) Fail(ctx context.Context, job *Job, cause error) error {
|
||||
msg := cause.Error()
|
||||
if len(msg) > 500 {
|
||||
msg = msg[:500] + "…"
|
||||
}
|
||||
status := "pending"
|
||||
if job.Attempts >= maxAttempts {
|
||||
status = "failed"
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE ocr_jobs SET status = $2, last_error = $3, updated_at = NOW() WHERE id = $1`,
|
||||
job.ID, status, msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip schließt einen Auftrag ohne Text ab – etwa wenn die Datei inzwischen
|
||||
// gelöscht wurde. 'done' statt 'failed': Es ist nichts schiefgegangen.
|
||||
func (s *Store) Skip(ctx context.Context, jobID uuid.UUID, reason string) error {
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE ocr_jobs SET status = 'done', last_error = $2, updated_at = NOW() WHERE id = $1`,
|
||||
jobID, reason)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stats zählt die Aufträge je Zustand – für die Anzeige im Admin-Bereich und
|
||||
// für die Prüfung, ob der Arbeiter überhaupt vorankommt.
|
||||
type Stats struct {
|
||||
Pending int `json:"pending"`
|
||||
Running int `json:"running"`
|
||||
Done int `json:"done"`
|
||||
Failed int `json:"failed"`
|
||||
// Indexed ist die Zahl der Dateien mit gespeichertem Text.
|
||||
Indexed int `json:"indexed"`
|
||||
}
|
||||
|
||||
func (s *Store) Stats(ctx context.Context) (Stats, error) {
|
||||
var st Stats
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE status = 'pending'),
|
||||
COUNT(*) FILTER (WHERE status = 'running'),
|
||||
COUNT(*) FILTER (WHERE status = 'done'),
|
||||
COUNT(*) FILTER (WHERE status = 'failed'),
|
||||
(SELECT COUNT(*) FROM file_text)
|
||||
FROM ocr_jobs`,
|
||||
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("ocr stats: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// StatsForUser zählt nur, was diesem Nutzer gehört.
|
||||
//
|
||||
// Warum es das getrennt von Stats gibt: Den Gesamtstand darf nur der
|
||||
// Administrator sehen – die Zahl aller Dokumente auf dem Server verrät etwas
|
||||
// über fremde Bestände. Wissen muss es aber genau der, der die Rechnung
|
||||
// hochgeladen hat: Findet seine Suche sie nicht, ist die Frage „ist sie schon
|
||||
// gelesen?" die erste, die er stellt.
|
||||
//
|
||||
// Gezählt wird über owner_id, also die eigenen Dateien. Dateien in
|
||||
// Gruppenordnern bleiben außen vor – sie gehören der Gruppe, und ihr Stand
|
||||
// wäre für den Einzelnen keine Auskunft über den eigenen Bestand.
|
||||
func (s *Store) StatsForUser(ctx context.Context, userID uuid.UUID) (Stats, error) {
|
||||
var st Stats
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FILTER (WHERE j.status = 'pending'),
|
||||
COUNT(*) FILTER (WHERE j.status = 'running'),
|
||||
COUNT(*) FILTER (WHERE j.status = 'done'),
|
||||
COUNT(*) FILTER (WHERE j.status = 'failed'),
|
||||
COUNT(*) FILTER (WHERE t.file_id IS NOT NULL)
|
||||
FROM files f
|
||||
JOIN ocr_jobs j ON j.file_id = f.id
|
||||
LEFT JOIN file_text t ON t.file_id = f.id
|
||||
WHERE f.owner_id = $1 AND f.is_deleted = false`,
|
||||
userID,
|
||||
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
|
||||
if err != nil {
|
||||
return st, fmt.Errorf("ocr stats for user: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// EnqueueMissing trägt alle Bestandsdateien nach, die noch keinen Auftrag
|
||||
// haben. Ohne das bliebe alles unauffindbar, was vor dem Einbau hochgeladen
|
||||
// wurde – und das ist beim ersten Start genau der gesamte Bestand.
|
||||
//
|
||||
// Die Auswahl der Typen passiert hier in SQL statt über Supported(): Bei
|
||||
// zehntausend Dateien wäre es Unfug, jede einzelne nach Go zu holen, nur um
|
||||
// ihren MIME-Typ anzusehen.
|
||||
func (s *Store) EnqueueMissing(ctx context.Context, limit int) (int, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ocr_jobs (file_id, status)
|
||||
SELECT f.id, 'pending'
|
||||
FROM files f
|
||||
WHERE f.is_deleted = false
|
||||
AND (f.mime_type ILIKE '%pdf%'
|
||||
OR (f.mime_type ILIKE 'image/%' AND f.mime_type NOT ILIKE '%svg%')
|
||||
OR f.name ~* '\.(pdf|png|jpe?g|tiff?|bmp|webp)$')
|
||||
AND NOT EXISTS (SELECT 1 FROM ocr_jobs j WHERE j.file_id = f.id)
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1
|
||||
ON CONFLICT (file_id) DO NOTHING`, limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enqueue missing: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// EnqueueOutdated legt Einträge erneut in die Warteschlange, die nach einer
|
||||
// älteren Regel entstanden sind.
|
||||
//
|
||||
// Anlass war die Seitenmarke: Bis Fassung 1 wurde der Seitenumbruch aus dem
|
||||
// Text entfernt. Wer damit indiziert wurde, ist zwar auffindbar – aber der
|
||||
// Betrachter kann bei einem Scan nicht sagen, auf welcher Seite das Wort
|
||||
// steht. Ein stiller Rest, der sich nur durch erneutes Erkennen auflöst.
|
||||
//
|
||||
// Bewusst mit derselben Obergrenze wie EnqueueMissing: Bei einem großen
|
||||
// Bestand soll ein Neustart nicht die ganze Platte an OCR verheizen, sondern
|
||||
// den Rückstand über mehrere Starte abtragen.
|
||||
func (s *Store) EnqueueOutdated(ctx context.Context, limit int) (int, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ocr_jobs (file_id, status)
|
||||
SELECT t.file_id, 'pending'
|
||||
FROM file_text t
|
||||
JOIN files f ON f.id = t.file_id AND f.is_deleted = false
|
||||
WHERE t.extract_version < $2
|
||||
LIMIT $1
|
||||
ON CONFLICT (file_id) DO UPDATE
|
||||
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
|
||||
limit, ExtractVersion)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enqueue outdated: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// TextPages liefert den erkannten Text einer Datei, in Seiten zerlegt.
|
||||
//
|
||||
// Für den PDF-Betrachter: Bei einem Scan steht im PDF selbst kein Wort, nur
|
||||
// ein Bild. Ohne diesen Weg fände die Suche im Dokument nichts – ausgerechnet
|
||||
// bei dem Dateityp, für den der erkannte Text überhaupt gebaut wurde.
|
||||
//
|
||||
// Die Zugriffsprüfung passiert im Aufrufer; hier liegt nur die Abfrage.
|
||||
func (s *Store) TextPages(ctx context.Context, fileID uuid.UUID) ([]string, string, error) {
|
||||
var content, source string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT content, source FROM file_text WHERE file_id = $1`, fileID,
|
||||
).Scan(&content, &source)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("text pages: %w", err)
|
||||
}
|
||||
return strings.Split(content, "\f"), source, nil
|
||||
}
|
||||
|
||||
// DeleteText entfernt den Text einer Datei. Wird beim endgültigen Löschen
|
||||
// gebraucht; solange die Datei nur im Papierkorb liegt, bleibt er stehen –
|
||||
// beim Wiederherstellen wäre er sonst weg.
|
||||
func (s *Store) DeleteText(ctx context.Context, fileID uuid.UUID) error {
|
||||
_, err := s.db.Exec(ctx, `DELETE FROM file_text WHERE file_id = $1`, fileID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||
//
|
||||
// Abfragen statt Benachrichtigen (LISTEN/NOTIFY) ist hier die richtige Wahl:
|
||||
// Eine Abfrage auf einen Teilindex kostet nichts, und der Arbeiter muss ohnehin
|
||||
// regelmäßig nachsehen – ein Auftrag kann auch dadurch entstehen, dass der
|
||||
// Server beim letzten Mal mittendrin ausgefallen ist. Fünf Sekunden Verzug
|
||||
// spielen bei einer Aufgabe, die selbst Sekunden bis Minuten dauert, keine
|
||||
// Rolle.
|
||||
const idleInterval = 5 * time.Second
|
||||
|
||||
// busyInterval: Wurde gerade etwas erledigt, sofort weitermachen. Eine kurze
|
||||
// Pause bleibt trotzdem, damit ein Stapel von hundert Scans nicht die ganze
|
||||
// Maschine belegt und die Cloud daneben zäh wird.
|
||||
const busyInterval = 200 * time.Millisecond
|
||||
|
||||
// maxFileBytes: Größeres wird übersprungen. Der Arbeiter muss die Datei in eine
|
||||
// temporäre Kopie schreiben, bevor die Werkzeuge sie lesen können; ein
|
||||
// 4-GB-Video würde dabei die Platte füllen – und Text ist darin ohnehin keiner.
|
||||
const maxFileBytes = 512 << 20 // 512 MiB
|
||||
|
||||
// Worker arbeitet die Warteschlange ab. Einer genügt: OCR lastet einen Kern
|
||||
// voll aus, und mehrere parallel machen auf einem Heimserver die Oberfläche
|
||||
// zäh, ohne insgesamt schneller fertig zu sein.
|
||||
type Worker struct {
|
||||
store *Store
|
||||
blobs storage.Storage
|
||||
extract *Extractor
|
||||
log zerolog.Logger
|
||||
// paused merkt sich, ob wegen Platzmangels gerade nichts läuft – nur damit
|
||||
// die Warnung einmal im Log steht und nicht alle fünf Sekunden.
|
||||
paused bool
|
||||
}
|
||||
|
||||
func NewWorker(store *Store, blobs storage.Storage, ex *Extractor, log zerolog.Logger) *Worker {
|
||||
return &Worker{store: store, blobs: blobs, extract: ex, log: log.With().Str("component", "ocr").Logger()}
|
||||
}
|
||||
|
||||
// Run läuft, bis der Kontext endet. Gedacht für `go worker.Run(ctx)` beim Start.
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
w.log.Info().
|
||||
Str("languages", w.extract.Languages).
|
||||
Int("max_pages", w.extract.MaxPages).
|
||||
Msg("OCR-Arbeiter gestartet")
|
||||
|
||||
// Angefangene Dateien eines hart beendeten Laufs wegräumen (siehe
|
||||
// sweepTemp). Einmal beim Start genügt: Während des Betriebs räumt der
|
||||
// Arbeiter selbst auf.
|
||||
if n, freed := sweepTemp(w.extract.TmpDir); n > 0 {
|
||||
w.log.Info().Int("files", n).Int64("bytes", freed).
|
||||
Msg("Zwischendateien eines früheren Laufs entfernt")
|
||||
}
|
||||
|
||||
for {
|
||||
wait := idleInterval
|
||||
if w.step(ctx) {
|
||||
wait = busyInterval
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.log.Info().Msg("OCR-Arbeiter beendet")
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step erledigt höchstens einen Auftrag. Rückgabe: ob etwas zu tun war.
|
||||
func (w *Worker) step(ctx context.Context) bool {
|
||||
// Vor dem Zugreifen nachsehen, nicht danach: Ein Auftrag, den wir schon
|
||||
// auf 'running' gesetzt haben, hätte einen Versuch verbraucht, obwohl es
|
||||
// gar nicht an ihm lag.
|
||||
if free, err := freeBytes(w.extract.TmpDir); err == nil && free < minFreeBytes {
|
||||
// Nur einmal je Zustandswechsel melden – sonst füllt die Warnung im
|
||||
// Fünf-Sekunden-Takt den Log, und das ausgerechnet, wenn der Platz
|
||||
// knapp ist.
|
||||
if !w.paused {
|
||||
w.paused = true
|
||||
w.log.Warn().
|
||||
Uint64("free_mb", free/(1<<20)).
|
||||
Uint64("needed_mb", uint64(minFreeBytes)/(1<<20)).
|
||||
Msg("Texterkennung pausiert: zu wenig Platz auf der Platte")
|
||||
}
|
||||
return false
|
||||
} else if w.paused {
|
||||
w.paused = false
|
||||
w.log.Info().Msg("Texterkennung fortgesetzt: wieder genug Platz")
|
||||
}
|
||||
|
||||
job, err := w.store.Claim(ctx)
|
||||
if errors.Is(err, ErrNoJob) {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
// Datenbank weg oder Kontext beendet – nicht in einer engen Schleife
|
||||
// weiterprobieren.
|
||||
if ctx.Err() == nil {
|
||||
w.log.Warn().Err(err).Msg("Auftrag holen fehlgeschlagen")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := w.process(ctx, job)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Herunterfahren mitten im Lauf ist kein Fehler der Datei. Der
|
||||
// Auftrag steht auf 'running' und wird beim nächsten Start wieder
|
||||
// aufgegriffen – genau dafür nimmt Claim auch 'running' mit.
|
||||
return false
|
||||
}
|
||||
w.log.Warn().Err(err).
|
||||
Str("file", job.Name).Int("attempt", job.Attempts).
|
||||
Msg("Texterkennung fehlgeschlagen")
|
||||
if err := w.store.Fail(context.WithoutCancel(ctx), job, err); err != nil {
|
||||
w.log.Error().Err(err).Msg("Fehlschlag konnte nicht vermerkt werden")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if err := w.store.Complete(ctx, job, res); err != nil {
|
||||
w.log.Error().Err(err).Str("file", job.Name).Msg("Text konnte nicht gespeichert werden")
|
||||
return true
|
||||
}
|
||||
w.log.Info().
|
||||
Str("file", job.Name).
|
||||
Str("source", res.Source).
|
||||
Int("chars", res.Chars).
|
||||
Int("pages", res.Pages).
|
||||
Dur("took", time.Since(start)).
|
||||
Msg("Text erkannt")
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Worker) process(ctx context.Context, job *Job) (Result, error) {
|
||||
if job.Size > maxFileBytes {
|
||||
return Result{}, fmt.Errorf("Datei zu groß für die Texterkennung (%d Bytes)", job.Size)
|
||||
}
|
||||
if !Supported(job.MimeType, job.Name) {
|
||||
// Kann vorkommen, wenn der MIME-Typ nachträglich korrigiert wurde.
|
||||
// Kein Fehler – nur nichts zu tun.
|
||||
return Result{}, w.store.Skip(ctx, job.ID, "Dateityp trägt keinen Text")
|
||||
}
|
||||
|
||||
// Die Werkzeuge sind externe Programme und brauchen einen Pfad; die
|
||||
// Ablageschicht gibt grundsätzlich nur einen Datenstrom heraus, weil sie
|
||||
// später auch S3 bedienen soll.
|
||||
//
|
||||
// Bei der lokalen Ablage gibt es den Pfad aber wirklich – dann wird
|
||||
// **nicht** kopiert. Das ist kein Feinschliff: Eine Kopie bedeutet, dass
|
||||
// neben jedem Dokument kurzzeitig noch einmal dieselbe Menge auf der
|
||||
// Platte liegt, und die Erkennung läuft ausgerechnet auf einer Maschine,
|
||||
// die auch alle Dateien beherbergt.
|
||||
path, cleanup, err := w.materialize(ctx, job)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return w.extract.Extract(ctx, path, job.MimeType, job.Name)
|
||||
}
|
||||
|
||||
// materialize besorgt einen Pfad zur Datei – ohne Kopie, wo es geht.
|
||||
//
|
||||
// Der zurückgegebene cleanup räumt nur auf, wenn tatsächlich kopiert wurde. Er
|
||||
// darf das Original niemals löschen; deshalb die klare Trennung der beiden
|
||||
// Zweige statt eines gemeinsamen Pfads mit einem Merker.
|
||||
func (w *Worker) materialize(ctx context.Context, job *Job) (string, func(), error) {
|
||||
if lp, ok := w.blobs.(storage.LocalPather); ok {
|
||||
path, err := lp.LocalPath(job.StorageKey)
|
||||
if err == nil {
|
||||
// Nichts zu räumen: Das ist die Datei selbst. Gelesen wird sie nur.
|
||||
return path, func() {}, nil
|
||||
}
|
||||
// Kein Abbruch – der Weg über die Kopie funktioniert weiterhin.
|
||||
w.log.Debug().Err(err).Str("key", job.StorageKey).
|
||||
Msg("kein lokaler Pfad, weiche auf Kopie aus")
|
||||
}
|
||||
|
||||
rc, err := w.blobs.Get(ctx, job.StorageKey)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Datei lesen: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Die Endung mitnehmen: ocrmypdf und tesseract entscheiden auch danach,
|
||||
// was sie vor sich haben.
|
||||
pattern := "ocr-*" + extOf(job.Name)
|
||||
var tmp *os.File
|
||||
if tf, ok := w.blobs.(storage.TempFiler); ok {
|
||||
// Über die Ablageschicht, damit die Kopie auf derselben Platte landet
|
||||
// wie die Daten – nicht in einem knappen /tmp im Container.
|
||||
tmp, err = tf.CreateTemp(pattern)
|
||||
} else {
|
||||
tmp, err = os.CreateTemp("", pattern)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Zwischendatei anlegen: %w", err)
|
||||
}
|
||||
name := tmp.Name()
|
||||
cleanup := func() { os.Remove(name) }
|
||||
|
||||
if _, err := io.Copy(tmp, rc); err != nil {
|
||||
tmp.Close()
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("Zwischendatei schreiben: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("Zwischendatei schließen: %w", err)
|
||||
}
|
||||
return name, cleanup, nil
|
||||
}
|
||||
|
||||
func extOf(name string) string {
|
||||
for i := len(name) - 1; i >= 0 && len(name)-i <= 6; i-- {
|
||||
if name[i] == '.' {
|
||||
return name[i:]
|
||||
}
|
||||
if name[i] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user