monorepo/native/desktop/maplefile/internal/app/app_search.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
}