mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
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:
@@ -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, " ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user