324 lines
11 KiB
Go
324 lines
11 KiB
Go
// app_search.go contains the search-related application layer code.
|
|
//
|
|
// This file provides:
|
|
// - Search index initialization and rebuild logic
|
|
// - Wails bindings for frontend search functionality
|
|
// - File and collection indexing helpers
|
|
//
|
|
// The search feature uses Bleve for local full-text search. Each user has their
|
|
// own isolated search index stored in their local application data directory.
|
|
// Search results are deduplicated by filename to avoid showing the same file
|
|
// multiple times when it exists in multiple collections.
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
|
|
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/search"
|
|
)
|
|
|
|
// =============================================================================
|
|
// SEARCH INDEXING OPERATIONS
|
|
// These functions are called internally when files/collections are created,
|
|
// updated, or when the index needs to be rebuilt.
|
|
// =============================================================================
|
|
|
|
// indexFileForSearch indexes a single file in the search index.
|
|
// This is called when a new file is uploaded to add it to the search index immediately.
|
|
func (a *Application) indexFileForSearch(fileID, collectionID, filename string, tags []string, size int64) error {
|
|
// Get collection name for denormalization
|
|
collectionName := ""
|
|
collectionRepo := a.getCollectionRepo()
|
|
if collectionRepo != nil {
|
|
collection, err := collectionRepo.Get(collectionID)
|
|
if err == nil && collection != nil {
|
|
collectionName = collection.Name
|
|
}
|
|
}
|
|
|
|
// Create file document for search
|
|
fileDoc := &search.FileDocument{
|
|
ID: fileID,
|
|
Filename: filename,
|
|
Description: "", // No description field in current implementation
|
|
CollectionID: collectionID,
|
|
CollectionName: collectionName,
|
|
Tags: tags,
|
|
Size: size,
|
|
CreatedAt: time.Now(),
|
|
Type: "file",
|
|
}
|
|
|
|
return a.searchService.IndexFile(fileDoc)
|
|
}
|
|
|
|
// indexCollectionForSearch indexes a collection in the search index
|
|
func (a *Application) indexCollectionForSearch(collectionID, name string, tags []string, fileCount int) error {
|
|
// Create collection document for search
|
|
collectionDoc := &search.CollectionDocument{
|
|
ID: collectionID,
|
|
Name: name,
|
|
Description: "", // No description field in current implementation
|
|
Tags: tags,
|
|
FileCount: fileCount,
|
|
CreatedAt: time.Now(),
|
|
Type: "collection",
|
|
}
|
|
|
|
return a.searchService.IndexCollection(collectionDoc)
|
|
}
|
|
|
|
// InitializeSearchIndex initializes the search index for the current user.
|
|
// This can be called manually if the index needs to be initialized.
|
|
func (a *Application) InitializeSearchIndex() error {
|
|
a.logger.Info("Manually initializing search index")
|
|
|
|
// Get current session to get user email
|
|
session, err := a.authService.GetCurrentSession(a.ctx)
|
|
if err != nil || session == nil {
|
|
return fmt.Errorf("no active session found")
|
|
}
|
|
|
|
// Initialize the search service
|
|
if err := a.searchService.Initialize(a.ctx, session.Email); err != nil {
|
|
a.logger.Error("Failed to initialize search index", zap.Error(err))
|
|
return fmt.Errorf("failed to initialize search index: %w", err)
|
|
}
|
|
|
|
a.logger.Info("Search index initialized successfully")
|
|
|
|
// Rebuild index from local data
|
|
if err := a.rebuildSearchIndexForUser(session.Email); err != nil {
|
|
a.logger.Warn("Failed to rebuild search index", zap.Error(err))
|
|
return fmt.Errorf("failed to rebuild search index: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// rebuildSearchIndexForUser rebuilds the entire search index from the local file repository.
|
|
// This is called on app startup and after login to ensure the search index is up-to-date.
|
|
// The rebuild process:
|
|
// 1. Lists all files from the local repository
|
|
// 2. Deduplicates files by ID (in case of repository corruption)
|
|
// 3. Skips deleted files
|
|
// 4. Passes all files to the search service for batch indexing
|
|
func (a *Application) rebuildSearchIndexForUser(userEmail string) error {
|
|
a.logger.Info("Rebuilding search index from local data", zap.String("email", userEmail))
|
|
|
|
fileRepo := a.getFileRepo()
|
|
if fileRepo == nil {
|
|
return fmt.Errorf("file repository not available")
|
|
}
|
|
|
|
// Get all local files
|
|
localFiles, err := fileRepo.List()
|
|
if err != nil {
|
|
a.logger.Error("Failed to list files for search index rebuild", zap.Error(err))
|
|
return fmt.Errorf("failed to list files: %w", err)
|
|
}
|
|
|
|
// Convert to search documents - use map to deduplicate by ID
|
|
fileDocumentsMap := make(map[string]*search.FileDocument)
|
|
for _, f := range localFiles {
|
|
// Skip deleted files
|
|
if f.State == file.StateDeleted {
|
|
continue
|
|
}
|
|
|
|
// Check for duplicates in local file repo
|
|
if _, exists := fileDocumentsMap[f.ID]; exists {
|
|
a.logger.Warn("Duplicate file found in local repository",
|
|
zap.String("id", f.ID),
|
|
zap.String("name", f.Name))
|
|
continue
|
|
}
|
|
|
|
// Get collection name if available
|
|
collectionName := ""
|
|
collectionRepo := a.getCollectionRepo()
|
|
if collectionRepo != nil {
|
|
collection, err := collectionRepo.Get(f.CollectionID)
|
|
if err == nil && collection != nil {
|
|
collectionName = collection.Name
|
|
}
|
|
}
|
|
|
|
fileDoc := &search.FileDocument{
|
|
ID: f.ID,
|
|
Filename: f.Name,
|
|
Description: "",
|
|
CollectionID: f.CollectionID,
|
|
CollectionName: collectionName,
|
|
Tags: []string{}, // Tags not stored in file entity currently
|
|
Size: f.DecryptedSizeInBytes,
|
|
CreatedAt: f.CreatedAt,
|
|
Type: "file",
|
|
}
|
|
fileDocumentsMap[f.ID] = fileDoc
|
|
}
|
|
|
|
// Convert map to slice
|
|
fileDocuments := make([]*search.FileDocument, 0, len(fileDocumentsMap))
|
|
for _, doc := range fileDocumentsMap {
|
|
fileDocuments = append(fileDocuments, doc)
|
|
}
|
|
|
|
a.logger.Info("Prepared files for indexing",
|
|
zap.Int("total_from_repo", len(localFiles)),
|
|
zap.Int("unique_files", len(fileDocuments)))
|
|
|
|
// For now, we don't index collections separately since they're fetched from cloud
|
|
// Collections will be indexed when they're explicitly created/updated
|
|
collectionDocuments := []*search.CollectionDocument{}
|
|
|
|
// Rebuild the index
|
|
if err := a.searchService.RebuildIndex(userEmail, fileDocuments, collectionDocuments); err != nil {
|
|
a.logger.Error("Failed to rebuild search index", zap.Error(err))
|
|
return fmt.Errorf("failed to rebuild search index: %w", err)
|
|
}
|
|
|
|
a.logger.Info("Search index rebuilt successfully",
|
|
zap.Int("files_indexed", len(fileDocuments)),
|
|
zap.Int("collections_indexed", len(collectionDocuments)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// WAILS BINDINGS - Exposed to Frontend
|
|
// =============================================================================
|
|
|
|
// SearchInput represents the input for search
|
|
type SearchInput struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit,omitempty"`
|
|
}
|
|
|
|
// SearchResultData represents search results for the frontend
|
|
type SearchResultData struct {
|
|
Files []FileSearchResult `json:"files"`
|
|
Collections []CollectionSearchResult `json:"collections"`
|
|
TotalFiles int `json:"total_files"`
|
|
TotalCollections int `json:"total_collections"`
|
|
TotalHits uint64 `json:"total_hits"`
|
|
MaxScore float64 `json:"max_score"`
|
|
Query string `json:"query"`
|
|
}
|
|
|
|
// FileSearchResult represents a file in search results
|
|
type FileSearchResult struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
CollectionID string `json:"collection_id"`
|
|
CollectionName string `json:"collection_name"`
|
|
Tags []string `json:"tags"`
|
|
Size int64 `json:"size"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// CollectionSearchResult represents a collection in search results
|
|
type CollectionSearchResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Tags []string `json:"tags"`
|
|
FileCount int `json:"file_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// Search performs a full-text search across files and collections.
|
|
// This is the main Wails binding exposed to the frontend for search functionality.
|
|
//
|
|
// Features:
|
|
// - Case-insensitive substring matching (e.g., "mesh" finds "meshtastic")
|
|
// - Deduplication by filename (same filename in multiple collections shows once)
|
|
// - Auto-initialization if search index is not ready
|
|
// - Support for Bleve query syntax (+, -, "", *, ?)
|
|
func (a *Application) Search(input SearchInput) (*SearchResultData, error) {
|
|
a.logger.Info("Performing search", zap.String("query", input.Query))
|
|
|
|
// Validate input
|
|
if input.Query == "" {
|
|
return nil, fmt.Errorf("search query cannot be empty")
|
|
}
|
|
|
|
// Set default limit if not specified
|
|
limit := input.Limit
|
|
if limit == 0 {
|
|
limit = 50
|
|
}
|
|
|
|
// Perform search
|
|
result, err := a.searchService.Search(input.Query, limit)
|
|
if err != nil {
|
|
// If search index is not initialized, try to initialize it automatically
|
|
if err.Error() == "search index not initialized" {
|
|
a.logger.Warn("Search index not initialized, attempting to initialize now")
|
|
if initErr := a.InitializeSearchIndex(); initErr != nil {
|
|
a.logger.Error("Failed to auto-initialize search index", zap.Error(initErr))
|
|
return nil, fmt.Errorf("search index not initialized. Please log out and log back in, or contact support")
|
|
}
|
|
// Retry search after initialization
|
|
result, err = a.searchService.Search(input.Query, limit)
|
|
if err != nil {
|
|
a.logger.Error("Search failed after auto-initialization", zap.String("query", input.Query), zap.Error(err))
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
} else {
|
|
a.logger.Error("Search failed", zap.String("query", input.Query), zap.Error(err))
|
|
return nil, fmt.Errorf("search failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Convert to frontend format with deduplication by filename
|
|
// Only show one file per unique filename (first occurrence wins)
|
|
files := make([]FileSearchResult, 0, len(result.Files))
|
|
seenFilenames := make(map[string]bool)
|
|
for _, f := range result.Files {
|
|
// Skip if we've already seen this filename
|
|
if seenFilenames[f.Filename] {
|
|
continue
|
|
}
|
|
seenFilenames[f.Filename] = true
|
|
|
|
files = append(files, FileSearchResult{
|
|
ID: f.ID,
|
|
Filename: f.Filename,
|
|
CollectionID: f.CollectionID,
|
|
CollectionName: f.CollectionName,
|
|
Tags: f.Tags,
|
|
Size: f.Size,
|
|
CreatedAt: f.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
collections := make([]CollectionSearchResult, 0, len(result.Collections))
|
|
for _, c := range result.Collections {
|
|
collections = append(collections, CollectionSearchResult{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
Tags: c.Tags,
|
|
FileCount: c.FileCount,
|
|
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
a.logger.Info("Search completed",
|
|
zap.String("query", input.Query),
|
|
zap.Int("files_found", len(files)),
|
|
zap.Int("collections_found", len(collections)))
|
|
|
|
return &SearchResultData{
|
|
Files: files,
|
|
Collections: collections,
|
|
TotalFiles: len(files),
|
|
TotalCollections: len(collections),
|
|
TotalHits: result.TotalHits,
|
|
MaxScore: result.MaxScore,
|
|
Query: input.Query,
|
|
}, nil
|
|
}
|