Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
324
native/desktop/maplefile/internal/app/app_search.go
Normal file
324
native/desktop/maplefile/internal/app/app_search.go
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue