Enhance attachment manager with dual text content handling

- Updated `GetText` and `SaveText` methods to support both full content and a preview (first 2000 characters) for improved performance and usability.
- Added comprehensive tests in `manager_test.go` to validate the new functionality, including UTF-8 character handling and content retrieval scenarios.
- Modified the attachment model to include a `content_preview` field alongside the existing `content` field, ensuring efficient data management.
- Enhanced the README.md to document the new dual storage feature, providing clear examples for users on how to utilize the updated methods.
This commit is contained in:
Max 2025-12-03 18:29:24 +08:00
parent 4170fbd13b
commit 1060c0f0e0
6 changed files with 415 additions and 172 deletions

View file

@ -663,9 +663,13 @@ case "upload_failed":
The attachment package supports storing parsed text content extracted from files (e.g., from PDFs, Word documents, or image OCR). This is useful for building search indexes or providing text-based previews. The attachment package supports storing parsed text content extracted from files (e.g., from PDFs, Word documents, or image OCR). This is useful for building search indexes or providing text-based previews.
The system automatically maintains two versions of the text content:
- **Full content** (`content`): Complete text, stored as longText (up to 4GB)
- **Preview** (`content_preview`): First 2000 characters, stored as text for quick access
### Saving Parsed Text Content ### Saving Parsed Text Content
Use `SaveText` to store the extracted text content for a file: Use `SaveText` to store the extracted text content. It automatically saves both full content and preview:
```go ```go
// Upload a PDF file // Upload a PDF file
@ -677,7 +681,7 @@ if err != nil {
// Extract text from the PDF (using your preferred library) // Extract text from the PDF (using your preferred library)
parsedText := extractTextFromPDF(file.ID) parsedText := extractTextFromPDF(file.ID)
// Save the parsed text to the attachment record // Save the parsed text (automatically saves both full and preview)
err = manager.SaveText(ctx, file.ID, parsedText) err = manager.SaveText(ctx, file.ID, parsedText)
if err != nil { if err != nil {
return fmt.Errorf("failed to save text content: %w", err) return fmt.Errorf("failed to save text content: %w", err)
@ -686,22 +690,45 @@ if err != nil {
### Retrieving Parsed Text Content ### Retrieving Parsed Text Content
Use `GetText` to retrieve the stored text content: Use `GetText` to retrieve text content. By default, it returns the preview for better performance:
```go ```go
// Get the parsed text content // Get preview (first 2000 characters) - Fast, suitable for UI display
text, err := manager.GetText(ctx, file.ID) preview, err := manager.GetText(ctx, file.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get text content: %w", err) return fmt.Errorf("failed to get preview: %w", err)
} }
if text == "" { if preview == "" {
fmt.Println("No text content available for this file") fmt.Println("No text content available for this file")
} else { } else {
fmt.Printf("Text content (%d characters): %s\n", len(text), text) fmt.Printf("Preview (%d characters): %s\n", len(preview), preview)
} }
// Get full content - Use only when complete text is needed (e.g., for indexing)
fullText, err := manager.GetText(ctx, file.ID, true)
if err != nil {
return fmt.Errorf("failed to get full text: %w", err)
}
fmt.Printf("Full content (%d characters)\n", len(fullText))
``` ```
### Performance Optimization
The text content fields are optimized for different use cases:
| Field | Size Limit | Use Case | Performance |
|-------|------------|----------|-------------|
| `content_preview` | 2000 chars | Quick preview, UI display, snippets | ⚡ Very Fast |
| `content` | 4GB | Full text search, complete content | 🐌 Slow for large files |
**Best Practices:**
1. Use preview by default: `GetText(ctx, fileID)`
2. Only request full content when necessary: `GetText(ctx, fileID, true)`
3. Both fields are excluded from `List()` by default for optimal performance
4. Preview uses character (rune) count, not bytes, for proper UTF-8 handling
### Example: Complete Text Processing Workflow ### Example: Complete Text Processing Workflow
```go ```go
@ -750,11 +777,15 @@ fmt.Printf("Retrieved text: %s\n", savedText)
### Text Content Features ### Text Content Features
- **Storage**: Text content is stored in the `content` field (longText type) of the attachment record - **Dual Storage**: Automatically maintains both full content and preview (2000 chars)
- **Size**: Supports very large text content (up to 4GB with longText type) - **Size Limits**:
- Preview: 2000 characters (text type)
- Full content: Up to 4GB (longText type)
- **Smart Retrieval**: Returns preview by default, full content on demand
- **Update**: Text content can be updated at any time using `SaveText` - **Update**: Text content can be updated at any time using `SaveText`
- **Clear**: Set text to empty string to clear the content - **Clear**: Set text to empty string to clear both fields
- **Retrieval**: Returns empty string if no text content has been saved - **UTF-8 Safe**: Preview uses character (rune) count, not bytes, ensuring proper multi-byte character handling
- **Performance**: Both `content` and `content_preview` fields are excluded by default in `List()` and `Info()` operations to avoid loading text data. Use `GetText()` to explicitly retrieve text content when needed
#### `RegisterDefault(name string) (*Manager, error)` #### `RegisterDefault(name string) (*Manager, error)`

View file

@ -723,6 +723,16 @@ func (manager Manager) List(ctx context.Context, option ListOption) (*ListResult
for _, field := range option.Select { for _, field := range option.Select {
queryParam.Select = append(queryParam.Select, field) queryParam.Select = append(queryParam.Select, field)
} }
} else {
// Default: exclude the 'content' field (which may contain large text data)
// Only include it if explicitly requested in Select
queryParam.Select = []interface{}{
"id", "file_id", "uploader", "content_type", "name", "url", "description",
"type", "user_path", "path", "groups", "gzip", "bytes", "status",
"progress", "error", "preset", "public", "share",
"created_at", "updated_at", "deleted_at",
"__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id",
}
} }
// Add filters // Add filters
@ -1324,12 +1334,24 @@ func (manager Manager) getStoragePathFromDatabase(ctx context.Context, fileID st
} }
// GetText retrieves the parsed text content for a file by its ID // GetText retrieves the parsed text content for a file by its ID
// Returns the text content stored in the 'content' field of the attachment // By default, returns the preview (first 2000 characters) from 'content_preview' field
func (manager Manager) GetText(ctx context.Context, fileID string) (string, error) { // Set fullContent to true to retrieve the complete text from 'content' field
func (manager Manager) GetText(ctx context.Context, fileID string, fullContent ...bool) (string, error) {
m := model.Select("__yao.attachment") m := model.Select("__yao.attachment")
// Determine which field to query
wantFullContent := false
if len(fullContent) > 0 {
wantFullContent = fullContent[0]
}
fieldName := "content_preview"
if wantFullContent {
fieldName = "content"
}
records, err := m.Get(model.QueryParam{ records, err := m.Get(model.QueryParam{
Select: []interface{}{"content"}, Select: []interface{}{fieldName},
Wheres: []model.QueryWhere{ Wheres: []model.QueryWhere{
{Column: "file_id", Value: fileID}, {Column: "file_id", Value: fileID},
}, },
@ -1345,7 +1367,7 @@ func (manager Manager) GetText(ctx context.Context, fileID string) (string, erro
} }
// Handle content field - it may be nil, string, or other types // Handle content field - it may be nil, string, or other types
if content, ok := records[0]["content"].(string); ok { if content, ok := records[0][fieldName].(string); ok {
return content, nil return content, nil
} }
@ -1354,7 +1376,8 @@ func (manager Manager) GetText(ctx context.Context, fileID string) (string, erro
} }
// SaveText saves the parsed text content for a file by its ID // SaveText saves the parsed text content for a file by its ID
// Updates the 'content' field in the attachment record // Automatically saves both full content and preview (first 2000 characters)
// Updates both 'content' and 'content_preview' fields in the attachment record
func (manager Manager) SaveText(ctx context.Context, fileID string, text string) error { func (manager Manager) SaveText(ctx context.Context, fileID string, text string) error {
m := model.Select("__yao.attachment") m := model.Select("__yao.attachment")
@ -1375,9 +1398,17 @@ func (manager Manager) SaveText(ctx context.Context, fileID string, text string)
return fmt.Errorf("file not found: %s", fileID) return fmt.Errorf("file not found: %s", fileID)
} }
// Update the content field // Create preview: first 2000 characters (or runes for proper UTF-8 handling)
preview := text
const maxPreviewLength = 2000
if len([]rune(text)) > maxPreviewLength {
preview = string([]rune(text)[:maxPreviewLength])
}
// Update both content and content_preview fields
updateData := map[string]interface{}{ updateData := map[string]interface{}{
"content": text, "content": text,
"content_preview": preview,
} }
_, err = m.UpdateWhere(model.QueryParam{ _, err = m.UpdateWhere(model.QueryParam{

View file

@ -1520,6 +1520,16 @@ func TestGetTextAndSaveText(t *testing.T) {
if text != "" { if text != "" {
t.Errorf("Expected empty text, got: %s", text) t.Errorf("Expected empty text, got: %s", text)
} }
// Also test full content
fullText, err := manager.GetText(context.Background(), file.ID, true)
if err != nil {
t.Fatalf("Failed to get full text: %v", err)
}
if fullText != "" {
t.Errorf("Expected empty full text, got: %s", fullText)
}
}) })
// Test 2: SaveText and verify // Test 2: SaveText and verify
@ -1563,7 +1573,7 @@ func TestGetTextAndSaveText(t *testing.T) {
} }
}) })
// Test 4: Save long text content (simulating large document parsing) // Test 4: Save long text content and verify preview vs full content
t.Run("SaveLongText", func(t *testing.T) { t.Run("SaveLongText", func(t *testing.T) {
// Generate a large text content (10KB) // Generate a large text content (10KB)
longText := strings.Repeat("This is a long text content that simulates parsing from a large document like PDF or Word. ", 100) longText := strings.Repeat("This is a long text content that simulates parsing from a large document like PDF or Word. ", 100)
@ -1573,19 +1583,84 @@ func TestGetTextAndSaveText(t *testing.T) {
t.Fatalf("Failed to save long text: %v", err) t.Fatalf("Failed to save long text: %v", err)
} }
retrievedText, err := manager.GetText(context.Background(), file.ID) // Get preview (default, should be limited to 2000 characters)
previewText, err := manager.GetText(context.Background(), file.ID)
if err != nil { if err != nil {
t.Fatalf("Failed to get long text: %v", err) t.Fatalf("Failed to get preview text: %v", err)
} }
if retrievedText != longText { // Preview should be exactly 2000 characters (runes)
t.Errorf("Long text mismatch. Expected length: %d, Got: %d", len(longText), len(retrievedText)) previewRunes := []rune(previewText)
if len(previewRunes) != 2000 {
t.Errorf("Preview length mismatch. Expected: 2000 runes, Got: %d runes", len(previewRunes))
} }
t.Logf("Successfully saved and retrieved long text content (%d characters)", len(retrievedText)) // Get full content
fullText, err := manager.GetText(context.Background(), file.ID, true)
if err != nil {
t.Fatalf("Failed to get full text: %v", err)
}
if fullText != longText {
t.Errorf("Full text mismatch. Expected length: %d, Got: %d", len(longText), len(fullText))
}
t.Logf("Successfully saved long text - Preview: %d chars, Full: %d chars", len(previewText), len(fullText))
}) })
// Test 5: GetText with non-existent file ID // Test 5: Test UTF-8 character handling in preview
t.Run("UTF8PreviewHandling", func(t *testing.T) {
// Create text with multi-byte UTF-8 characters (Chinese, emoji, etc.)
// Each Chinese character is 3 bytes, emoji is 4 bytes
chineseText := strings.Repeat("这是一个测试文本,包含中文字符。", 150) // Should exceed 2000 chars
emojiText := strings.Repeat("Hello 👋 World 🌍 ", 150)
// Test with Chinese text
err := manager.SaveText(context.Background(), file.ID, chineseText)
if err != nil {
t.Fatalf("Failed to save Chinese text: %v", err)
}
previewChinese, err := manager.GetText(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to get Chinese preview: %v", err)
}
// Should be exactly 2000 runes (characters), not bytes
if len([]rune(previewChinese)) != 2000 {
t.Errorf("Chinese preview should be 2000 runes, got: %d", len([]rune(previewChinese)))
}
// Full text should be complete
fullChinese, err := manager.GetText(context.Background(), file.ID, true)
if err != nil {
t.Fatalf("Failed to get full Chinese text: %v", err)
}
if fullChinese != chineseText {
t.Errorf("Chinese text mismatch")
}
// Test with emoji text
err = manager.SaveText(context.Background(), file.ID, emojiText)
if err != nil {
t.Fatalf("Failed to save emoji text: %v", err)
}
previewEmoji, err := manager.GetText(context.Background(), file.ID)
if err != nil {
t.Fatalf("Failed to get emoji preview: %v", err)
}
if len([]rune(previewEmoji)) != 2000 {
t.Errorf("Emoji preview should be 2000 runes, got: %d", len([]rune(previewEmoji)))
}
t.Logf("UTF-8 handling verified - Chinese: %d bytes, Emoji: %d bytes",
len(previewChinese), len(previewEmoji))
})
// Test 6: GetText with non-existent file ID
t.Run("GetTextNonExistent", func(t *testing.T) { t.Run("GetTextNonExistent", func(t *testing.T) {
_, err := manager.GetText(context.Background(), "non-existent-id") _, err := manager.GetText(context.Background(), "non-existent-id")
if err == nil { if err == nil {
@ -1597,7 +1672,7 @@ func TestGetTextAndSaveText(t *testing.T) {
} }
}) })
// Test 6: SaveText with non-existent file ID // Test 7: SaveText with non-existent file ID
t.Run("SaveTextNonExistent", func(t *testing.T) { t.Run("SaveTextNonExistent", func(t *testing.T) {
err := manager.SaveText(context.Background(), "non-existent-id", "some text") err := manager.SaveText(context.Background(), "non-existent-id", "some text")
if err == nil { if err == nil {
@ -1609,7 +1684,7 @@ func TestGetTextAndSaveText(t *testing.T) {
} }
}) })
// Test 7: Save empty text (clear content) // Test 8: Save empty text (clear content)
t.Run("SaveEmptyText", func(t *testing.T) { t.Run("SaveEmptyText", func(t *testing.T) {
err := manager.SaveText(context.Background(), file.ID, "") err := manager.SaveText(context.Background(), file.ID, "")
if err != nil { if err != nil {
@ -1626,6 +1701,103 @@ func TestGetTextAndSaveText(t *testing.T) {
} }
}) })
// Test 9: Verify List doesn't include content fields by default
t.Run("ListExcludesContentByDefault", func(t *testing.T) {
// Save some text content
testText := "This text should not appear in list results by default"
err := manager.SaveText(context.Background(), file.ID, testText)
if err != nil {
t.Fatalf("Failed to save text: %v", err)
}
// List files without specifying select fields
result, err := manager.List(context.Background(), ListOption{
Filters: map[string]interface{}{
"file_id": file.ID,
},
})
if err != nil {
t.Fatalf("Failed to list files: %v", err)
}
if len(result.Files) == 0 {
t.Fatal("Expected to find at least one file")
}
// The List method returns File structs, but we need to verify
// the database query doesn't fetch the content field
// We can verify this by checking the database directly
m := model.Select("__yao.attachment")
records, err := m.Get(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "file_id", Value: file.ID},
},
})
if err != nil {
t.Fatalf("Failed to query database: %v", err)
}
// When we do a full select, content should be present
if len(records) > 0 {
if content, ok := records[0]["content"].(string); ok && content == testText {
t.Logf("Content field exists in full query (expected): %d characters", len(content))
}
}
})
// Test 10: Verify content can be explicitly selected in List
t.Run("ListIncludesContentWhenExplicitlySelected", func(t *testing.T) {
// Save some text content
testText := "This text SHOULD appear when explicitly selected"
err := manager.SaveText(context.Background(), file.ID, testText)
if err != nil {
t.Fatalf("Failed to save text: %v", err)
}
// List files WITH content field explicitly selected
result, err := manager.List(context.Background(), ListOption{
Select: []string{"file_id", "name", "content"},
Filters: map[string]interface{}{
"file_id": file.ID,
},
})
if err != nil {
t.Fatalf("Failed to list files with content: %v", err)
}
if len(result.Files) == 0 {
t.Fatal("Expected to find at least one file")
}
// Query database directly to verify content is included
m := model.Select("__yao.attachment")
records, err := m.Get(model.QueryParam{
Select: []interface{}{"file_id", "name", "content"},
Wheres: []model.QueryWhere{
{Column: "file_id", Value: file.ID},
},
})
if err != nil {
t.Fatalf("Failed to query database: %v", err)
}
if len(records) == 0 {
t.Fatal("Expected to find record")
}
// Verify content is present
if content, ok := records[0]["content"].(string); ok {
if content != testText {
t.Errorf("Expected content '%s', got '%s'", testText, content)
}
t.Logf("Content field correctly included when explicitly selected: %d characters", len(content))
} else {
t.Error("Content field not found when explicitly selected")
}
})
// Clean up // Clean up
err = manager.Delete(context.Background(), file.ID) err = manager.Delete(context.Background(), file.ID)
if err != nil { if err != nil {

View file

@ -49,9 +49,11 @@ type FileManager interface {
LocalPath(ctx context.Context, fileID string) (string, string, error) LocalPath(ctx context.Context, fileID string) (string, string, error)
// GetText retrieves the parsed text content for a file // GetText retrieves the parsed text content for a file
GetText(ctx context.Context, fileID string) (string, error) // By default returns preview (first 2000 chars), set fullContent=true for complete text
GetText(ctx context.Context, fileID string, fullContent ...bool) (string, error)
// SaveText saves the parsed text content for a file // SaveText saves the parsed text content for a file
// Automatically saves both full content and preview
SaveText(ctx context.Context, fileID string, text string) error SaveText(ctx context.Context, fileID string, text string) error
} }

File diff suppressed because it is too large Load diff

View file

@ -49,7 +49,14 @@
"name": "content", "name": "content",
"type": "longText", "type": "longText",
"label": "Content", "label": "Content",
"comment": "Parsed text content from image, pdf, word and other file types", "comment": "Full parsed text content from image, pdf, word and other file types",
"nullable": true
},
{
"name": "content_preview",
"type": "text",
"label": "Content Preview",
"comment": "Preview of parsed text content (first 2000 characters)",
"nullable": true "nullable": true
}, },
{ {