feat: index the text of attached PDFs
- jz_bm_material_file_texts caches the pdftotext output per file - the River job jz.bm.extract_material_text extracts and resyncs - the management multipart attach queues it inside its transaction - file_texts in the document and a file snippet with its name
This commit is contained in:
18
models/material_file_text.go
Normal file
18
models/material_file_text.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// MaterialFileText is the text extracted from one PDF attached to a
|
||||
// material. An empty Text means the file was extracted and held none (a
|
||||
// scan or a broken PDF). Truncated is set when the text was cut at the
|
||||
// extraction cap. The row goes with its file (foreign-key cascade).
|
||||
type MaterialFileText struct {
|
||||
FileID uint `gorm:"column:file_id;primaryKey;autoIncrement:false"`
|
||||
MaterialID uint `gorm:"column:material_id"`
|
||||
Text string `gorm:"column:text"`
|
||||
Truncated bool `gorm:"column:truncated"`
|
||||
ExtractedAt time.Time `gorm:"column:extracted_at"`
|
||||
}
|
||||
|
||||
// TableName is the file text table.
|
||||
func (MaterialFileText) TableName() string { return "jz_bm_material_file_texts" }
|
||||
@@ -58,7 +58,71 @@ func (m *Material) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MaterialDocument(*m, course, meetingNumber, tags, nil), nil
|
||||
files, err := IndexedFiles(db, []uint{m.ID}, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
texts := make([]string, 0, len(files[m.ID]))
|
||||
for _, f := range files[m.ID] {
|
||||
texts = append(texts, f.Text)
|
||||
}
|
||||
return MaterialDocument(*m, course, meetingNumber, tags, texts), nil
|
||||
}
|
||||
|
||||
// IndexedFile is one entry of a material document's file_texts: the file,
|
||||
// its name and, when asked for, its extracted text.
|
||||
type IndexedFile struct {
|
||||
FileID uint
|
||||
FileName string
|
||||
Text string
|
||||
}
|
||||
|
||||
// IndexedFiles returns, per material id, the files whose text the search
|
||||
// document holds, in document order: the current public files of the
|
||||
// material's files field that have a non-empty extracted text, by
|
||||
// sort_order (nulls last) and id. It is the one query that decides what
|
||||
// file_texts holds, so a snippet's array index maps back to a file through
|
||||
// it. A detached file is no longer a current file, so its text drops out
|
||||
// even before the cascade removes it. The text is read only when withText.
|
||||
func IndexedFiles(db *gorm.DB, materialIDs []uint, withText bool) (map[uint][]IndexedFile, error) {
|
||||
out := map[uint][]IndexedFile{}
|
||||
if len(materialIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ids := make([]string, 0, len(materialIDs))
|
||||
for _, id := range materialIDs {
|
||||
ids = append(ids, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
cols := "system_files.id AS file_id, system_files.file_name, system_files.attachment_id"
|
||||
if withText {
|
||||
cols += ", t.text"
|
||||
}
|
||||
var rows []struct {
|
||||
FileID uint
|
||||
FileName string
|
||||
AttachmentID string
|
||||
Text string
|
||||
}
|
||||
err := db.Table("system_files").
|
||||
Select(cols).
|
||||
Joins("JOIN jz_bm_material_file_texts t ON t.file_id = system_files.id").
|
||||
Where("system_files.attachment_type = ? AND system_files.field = ? AND system_files.is_public", Material{}.MorphName(), MaterialFilesField).
|
||||
Where("system_files.attachment_id IN ?", ids).
|
||||
Where("t.text <> ''").
|
||||
Order("system_files.attachment_id, system_files.sort_order NULLS LAST, system_files.id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
n, err := strconv.ParseUint(r.AttachmentID, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
id := uint(n)
|
||||
out[id] = append(out[id], IndexedFile{FileID: r.FileID, FileName: r.FileName, Text: r.Text})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MaterialDocument is the search document of m with its course, meeting
|
||||
|
||||
@@ -102,6 +102,16 @@ func TestMaterialDocument(t *testing.T) {
|
||||
t.Errorf("file_texts = %v", doc["file_texts"])
|
||||
}
|
||||
|
||||
// File texts keep their order (the IndexedFiles order); an empty list
|
||||
// leaves the key out.
|
||||
ordered := models.MaterialDocument(m, course, 3, tags, []string{"pierwszy plik", "drugi plik", "trzeci plik"})
|
||||
if got, _ := ordered["file_texts"].([]string); !slices.Equal(got, []string{"pierwszy plik", "drugi plik", "trzeci plik"}) {
|
||||
t.Errorf("file_texts = %v, want the three texts in order", ordered["file_texts"])
|
||||
}
|
||||
if _, ok := models.MaterialDocument(m, course, 3, tags, []string{})["file_texts"]; ok {
|
||||
t.Error("an empty file text list carries file_texts")
|
||||
}
|
||||
|
||||
// No code, no course id, no meeting, a draft, no files.
|
||||
bare := models.MaterialDocument(models.Material{ID: 5, Title: "Szkic", Type: "inne"}, &models.Course{Name: "Teoria"}, 0, nil, nil)
|
||||
if bare["course"] != "Teoria" {
|
||||
|
||||
Reference in New Issue
Block a user