// 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 }