fix: support searching by item ref (BUG-20) (#62)

* fix: support searching by item ref (e.g. TASK-5, BUG-8)

Search only used FTS5 on title/content/tags, but item refs are computed
from collection prefix + item number and were never indexed. Additionally,
FTS5 interpreted hyphens as NOT operators, causing SQL errors for
ref-like queries.

Add direct ref lookup before FTS so exact ref matches always appear
first, and sanitize FTS queries by quoting tokens to prevent special
character interpretation.

Fixes BUG-20.

* fix: return all ref matches across workspaces in search

Address Codex review: use Query instead of QueryRow for ref lookup so
unscoped searches (no workspace filter) return matching refs from all
workspaces, since refs are not globally unique.
This commit is contained in:
xarmian
2026-04-04 15:56:44 -04:00
committed by GitHub
parent edcf2ae8b0
commit 83f3d99b70
+91 -2
View File
@@ -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, " ")
}