diff --git a/internal/store/search.go b/internal/store/search.go index 4b64beed..fdbb8ca2 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -2,6 +2,7 @@ package store import ( "fmt" + "strings" "github.com/xarmian/pad/internal/models" ) @@ -18,6 +19,64 @@ type SearchParams struct { } func (s *Store) Search(params SearchParams) ([]SearchResult, error) { + var results []SearchResult + + // Check if the query looks like an item ref (e.g. "TASK-5", "BUG-8") + // and do a direct lookup first so refs are always findable. + if prefix, number, ok := parseItemRef(strings.TrimSpace(params.Query)); ok { + refQuery := ` + SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, + i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, + i.created_by, i.last_modified_by, i.source, + i.item_number, i.created_at, i.updated_at, + c.slug, c.name, c.icon, c.prefix, + COALESCE(au.name, ''), COALESCE(au.email, ''), + COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '') + FROM items i + JOIN collections c ON c.id = i.collection_id + LEFT JOIN users au ON au.id = i.assigned_user_id + LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id + WHERE c.prefix = ? AND i.item_number = ? AND i.deleted_at IS NULL + ` + refArgs := []interface{}{prefix, number} + + if params.Workspace != "" { + refQuery += ` AND i.workspace_id = (SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL)` + refArgs = append(refArgs, params.Workspace) + } + + refRows, err := s.db.Query(refQuery, refArgs...) + if err == nil { + defer refRows.Close() + for refRows.Next() { + var r SearchResult + var createdAt, updatedAt string + var pinned int + if err := refRows.Scan( + &r.Item.ID, &r.Item.WorkspaceID, &r.Item.CollectionID, &r.Item.Title, &r.Item.Slug, + &r.Item.Content, &r.Item.Fields, &r.Item.Tags, + &pinned, &r.Item.SortOrder, &r.Item.ParentID, &r.Item.AssignedUserID, &r.Item.AgentRoleID, &r.Item.RoleSortOrder, + &r.Item.CreatedBy, &r.Item.LastModifiedBy, + &r.Item.Source, &r.Item.ItemNumber, &createdAt, &updatedAt, + &r.Item.CollectionSlug, &r.Item.CollectionName, &r.Item.CollectionIcon, &r.Item.CollectionPrefix, + &r.Item.AssignedUserName, &r.Item.AssignedUserEmail, + &r.Item.AgentRoleName, &r.Item.AgentRoleSlug, &r.Item.AgentRoleIcon, + ); err != nil { + continue + } + r.Item.Pinned = pinned == 1 + r.Item.CreatedAt = parseTime(createdAt) + r.Item.UpdatedAt = parseTime(updatedAt) + hydrateItemComputedMetadata(&r.Item) + r.Item.Content = "" + r.Snippet = r.Item.Title + r.Rank = -1000 // Best possible rank so it sorts first + results = append(results, r) + } + } + // If no ref matches, fall through to FTS below + } + query := ` SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, i.fields, i.tags, i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order, @@ -36,7 +95,7 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { WHERE items_fts MATCH ? AND i.deleted_at IS NULL ` - args := []interface{}{params.Query} + args := []interface{}{sanitizeFTSQuery(params.Query)} if params.Workspace != "" { query += ` @@ -51,11 +110,21 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { rows, err := s.db.Query(query, args...) if err != nil { + // If we already have a ref match, return that instead of failing + // (FTS5 may reject queries like "TASK-5" due to special syntax) + if len(results) > 0 { + return results, nil + } return nil, fmt.Errorf("search: %w", err) } defer rows.Close() - var results []SearchResult + // Track IDs we already have from the ref lookup to avoid duplicates + seen := make(map[string]bool) + for _, r := range results { + seen[r.Item.ID] = true + } + for rows.Next() { var r SearchResult var createdAt, updatedAt string @@ -74,6 +143,10 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { ); err != nil { return nil, err } + // Skip if already included from ref lookup + if seen[r.Item.ID] { + continue + } r.Item.Pinned = pinned == 1 r.Item.CreatedAt = parseTime(createdAt) r.Item.UpdatedAt = parseTime(updatedAt) @@ -84,3 +157,19 @@ func (s *Store) Search(params SearchParams) ([]SearchResult, error) { } return results, rows.Err() } + +// sanitizeFTSQuery wraps each token in double quotes so FTS5 treats +// special characters (like hyphens) as literals rather than operators. +func sanitizeFTSQuery(q string) string { + q = strings.TrimSpace(q) + if q == "" { + return q + } + tokens := strings.Fields(q) + for i, t := range tokens { + // Remove any existing double quotes, then wrap in quotes + t = strings.ReplaceAll(t, `"`, ``) + tokens[i] = `"` + t + `"` + } + return strings.Join(tokens, " ") +}