mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools delegate to *store.Store (no business-logic duplication). - handler.go: handleRPC routes initialize / tools/list / tools/call / ping / notifications/initialized; Bearer-token middleware; results flow through the standard MCP content[].text envelope; tool errors surface as isError: true (transport errors stay JSON-RPC errors). - tools.go: 10 tools — list_items / get_item / create_item / update_item / delete_item / list_links / add_link / remove_link / search / tree. Multi-parent in/out — parent_paths[] string array, resolved per call. itemView/linkView keep the wire shape snake_case and stable. - mcp_test.go + tools_test.go: protocol primitives (no DB) plus a full create → get → search → delete round-trip skipping cleanly when the DB env is absent. Multi-parent assertion discovers the test pair from the live DB rather than hard-coding a row. store extensions: - ListByFilters(SearchFilters) with parent_path/tags/management/kind/ status/q/has_repo/has_caldav predicates. - Search(q, limit) ranked across title/slug/aliases/content_md. - GetByPathOrSlug for callers that don't know the full path. - SoftDeleteCascade refuses on live descendants unless cascade=true. web: - New optional Server.MCP http.Handler. main.go mounts an mcp.Server when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses the Supabase-cookie auth middleware (its own Bearer auth applies). - Off cleanly when the token is unset. ops: - ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out, Bearer header). - .mcp.json adds an http-transport entry for clients that speak HTTP+MCP natively. - deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret. - docs/design.md §7 added: tool list, multi-parent semantics, env contract, transport + bridge.
665 lines
20 KiB
Go
665 lines
20 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Item is the unified row shape served by projax.items_unified. Phase 1.5
|
|
// collapsed the area/project distinction (kind keeps the slot for future
|
|
// types but 'area' is no longer a special value) and extended the tree to
|
|
// a DAG: an item can have zero or more parents and surface under multiple
|
|
// paths simultaneously.
|
|
type Item struct {
|
|
ID string
|
|
Kind []string
|
|
Title string
|
|
Slug string
|
|
Paths []string // sorted, deduped — one entry per ancestor lineage
|
|
ParentIDs []string
|
|
ContentMD string
|
|
Aliases []string
|
|
Metadata map[string]any
|
|
Status string
|
|
Pinned bool
|
|
Archived bool
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Source string // always "projax" after Phase 1.5; kept for forward-compat
|
|
SourceRefID *string // mai.projects.id when a 'mai-project' item_links row exists
|
|
Tags []string
|
|
Management []string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// IsRoot reports whether this item sits at the top of the DAG (no parents).
|
|
func (it *Item) IsRoot() bool { return len(it.ParentIDs) == 0 }
|
|
|
|
// PrimaryPath returns the first path (alphabetically) for routing & display.
|
|
// Empty string when paths is empty (defensive — every persisted row has at
|
|
// least one path).
|
|
func (it *Item) PrimaryPath() string {
|
|
if len(it.Paths) == 0 {
|
|
return ""
|
|
}
|
|
return it.Paths[0]
|
|
}
|
|
|
|
// OtherPaths returns all paths except the primary one, for the "also at: …"
|
|
// breadcrumb on the detail page.
|
|
func (it *Item) OtherPaths() []string {
|
|
if len(it.Paths) <= 1 {
|
|
return nil
|
|
}
|
|
return it.Paths[1:]
|
|
}
|
|
|
|
// HasManagement reports whether the given mode (e.g. "mai") is set on the item.
|
|
func (it *Item) HasManagement(mode string) bool { return slices.Contains(it.Management, mode) }
|
|
|
|
// HasTag reports whether the item carries the given tag.
|
|
func (it *Item) HasTag(tag string) bool { return slices.Contains(it.Tags, tag) }
|
|
|
|
// Editable is preserved for template forward-compat. All rows are editable
|
|
// in projax after the mai.projects unification.
|
|
func (it *Item) Editable() bool { return true }
|
|
|
|
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
|
|
func (it *Item) SourceRefDeref() string {
|
|
if it.SourceRefID == nil {
|
|
return ""
|
|
}
|
|
return *it.SourceRefID
|
|
}
|
|
|
|
// Store wraps a pgx pool with the queries projax needs.
|
|
type Store struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
|
|
|
var ErrNotFound = errors.New("projax: item not found")
|
|
|
|
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
|
|
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
|
tags, management, created_at, updated_at`
|
|
|
|
func scanItem(row pgx.Row) (*Item, error) {
|
|
var it Item
|
|
if err := row.Scan(
|
|
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
|
|
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
|
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
|
&it.Tags, &it.Management,
|
|
&it.CreatedAt, &it.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &it, nil
|
|
}
|
|
|
|
func scanItems(rows pgx.Rows) ([]*Item, error) {
|
|
defer rows.Close()
|
|
var out []*Item
|
|
for rows.Next() {
|
|
var it Item
|
|
if err := rows.Scan(
|
|
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs, &it.ContentMD,
|
|
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
|
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
|
&it.Tags, &it.Management,
|
|
&it.CreatedAt, &it.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ListAll returns every visible row from items_unified. Caller groups by tree.
|
|
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified order by paths[1]`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// GetByPath looks up a single item by any of its paths. Multi-parent items
|
|
// can be accessed via /i/work.paliad or /i/dev.paliad interchangeably.
|
|
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
|
|
row := s.Pool.QueryRow(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified where $1 = any(paths) limit 1`, path)
|
|
it, err := scanItem(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// GetByID looks up a single projax-native item by uuid.
|
|
func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
|
|
row := s.Pool.QueryRow(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id)
|
|
it, err := scanItem(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// Roots returns the top-level items (no parents), ordered by slug.
|
|
func (s *Store) Roots(ctx context.Context) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified
|
|
where cardinality(parent_ids) = 0
|
|
order by slug`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// MaiOrphans lists mai-managed items that landed at root and need m to
|
|
// re-parent them. This includes both backfilled items that the heuristic
|
|
// misplaced and brand-new mai.projects rows created by mai code (which the
|
|
// reverse sync trigger drops at root by design).
|
|
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select `+itemsUnifiedCols+` from projax.items_unified
|
|
where cardinality(parent_ids) = 0 and 'mai' = any(management)
|
|
order by slug`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// CreateInput captures the editable surface of a projax-native item.
|
|
type CreateInput struct {
|
|
Kind []string
|
|
Title string
|
|
Slug string
|
|
ParentIDs []string
|
|
ContentMD string
|
|
Status string
|
|
Pinned bool
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Tags []string
|
|
Management []string
|
|
Metadata map[string]any
|
|
}
|
|
|
|
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
|
if len(in.Kind) == 0 {
|
|
return nil, errors.New("kind required")
|
|
}
|
|
if in.Title == "" {
|
|
return nil, errors.New("title required")
|
|
}
|
|
if in.Slug == "" {
|
|
return nil, errors.New("slug required")
|
|
}
|
|
if in.Status == "" {
|
|
in.Status = "active"
|
|
}
|
|
if in.Tags == nil {
|
|
in.Tags = []string{}
|
|
}
|
|
if in.Management == nil {
|
|
in.Management = []string{}
|
|
}
|
|
if in.ParentIDs == nil {
|
|
in.ParentIDs = []string{}
|
|
}
|
|
metadata := in.Metadata
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
}
|
|
var id string
|
|
err := s.Pool.QueryRow(ctx, `
|
|
insert into projax.items
|
|
(kind, title, slug, parent_ids, content_md, status, pinned, start_time, end_time,
|
|
tags, management, metadata)
|
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
returning id`,
|
|
in.Kind, in.Title, in.Slug, in.ParentIDs, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
|
|
in.Tags, in.Management, metadata,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// UpdateInput captures the editable surface of an existing projax-native item.
|
|
type UpdateInput struct {
|
|
Title string
|
|
Slug string
|
|
ParentIDs []string
|
|
ContentMD string
|
|
Status string
|
|
Pinned bool
|
|
Archived bool
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Tags []string
|
|
Management []string
|
|
}
|
|
|
|
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
|
if in.Tags == nil {
|
|
in.Tags = []string{}
|
|
}
|
|
if in.Management == nil {
|
|
in.Management = []string{}
|
|
}
|
|
if in.ParentIDs == nil {
|
|
in.ParentIDs = []string{}
|
|
}
|
|
_, err := s.Pool.Exec(ctx, `
|
|
update projax.items
|
|
set title=$2, slug=$3, parent_ids=$4, content_md=$5,
|
|
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10,
|
|
tags=$11, management=$12
|
|
where id=$1 and deleted_at is null`,
|
|
id, in.Title, in.Slug, in.ParentIDs, in.ContentMD,
|
|
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
|
|
in.Tags, in.Management,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// Reparent replaces parent_ids entirely with the given set. Used by the
|
|
// classify page's inline form and any "move to under X" action.
|
|
func (s *Store) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
|
|
if parentIDs == nil {
|
|
parentIDs = []string{}
|
|
}
|
|
_, err := s.Pool.Exec(ctx,
|
|
`update projax.items set parent_ids = $2 where id = $1 and deleted_at is null`,
|
|
id, parentIDs,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reparent: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// AddParent appends a parent without disturbing existing ones — used by the
|
|
// multi-parent UI to surface a project under a second branch.
|
|
func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
|
|
_, err := s.Pool.Exec(ctx, `
|
|
update projax.items
|
|
set parent_ids = case
|
|
when $2::uuid = any(parent_ids) then parent_ids
|
|
else array_append(parent_ids, $2::uuid)
|
|
end
|
|
where id = $1 and deleted_at is null`,
|
|
id, parentID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add parent: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// ItemLink mirrors a projax.item_links row — external pointer attached to
|
|
// an item (calendar URL, gitea repo, mai project id, …).
|
|
type ItemLink struct {
|
|
ID string
|
|
ItemID string
|
|
RefType string
|
|
RefID string
|
|
Rel string
|
|
Note *string
|
|
Metadata map[string]any
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// LinksByType returns every item_link of the given ref_type for one item.
|
|
func (s *Store) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
|
|
from projax.item_links
|
|
where item_id = $1 and ref_type = $2
|
|
order by created_at`, itemID, refType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*ItemLink
|
|
for rows.Next() {
|
|
var l ItemLink
|
|
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// LinksByRefType returns every item_link of the given ref_type across the
|
|
// whole schema. Used by /admin/caldav to find already-linked calendars.
|
|
func (s *Store) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
|
|
from projax.item_links
|
|
where ref_type = $1
|
|
order by created_at`, refType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*ItemLink
|
|
for rows.Next() {
|
|
var l ItemLink
|
|
if err := rows.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, &l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// AddLink inserts an item_link. ON CONFLICT (item_id, ref_type, ref_id, rel)
|
|
// the existing row is returned untouched.
|
|
func (s *Store) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
|
|
if rel == "" {
|
|
rel = "contains"
|
|
}
|
|
if metadata == nil {
|
|
metadata = map[string]any{}
|
|
}
|
|
var id string
|
|
err := s.Pool.QueryRow(ctx, `
|
|
insert into projax.item_links (item_id, ref_type, ref_id, rel, metadata)
|
|
values ($1, $2, $3, $4, $5)
|
|
on conflict (item_id, ref_type, ref_id, rel) do update set metadata = excluded.metadata
|
|
returning id`,
|
|
itemID, refType, refID, rel, metadata,
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("add link: %w", err)
|
|
}
|
|
row := s.Pool.QueryRow(ctx, `
|
|
select id, item_id, ref_type, ref_id, rel, note, metadata, created_at
|
|
from projax.item_links where id = $1`, id)
|
|
var l ItemLink
|
|
if err := row.Scan(&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note, &l.Metadata, &l.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// DeleteLink removes a single item_link by id.
|
|
func (s *Store) DeleteLink(ctx context.Context, id string) error {
|
|
_, err := s.Pool.Exec(ctx, `delete from projax.item_links where id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
|
|
// Used by the tree page filter chips.
|
|
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
|
|
rows, err := s.Pool.Query(ctx,
|
|
`select distinct unnest(tags) as tag from projax.items where deleted_at is null order by tag`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []string
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SoftDelete marks a projax-native item deleted_at = now().
|
|
func (s *Store) SoftDelete(ctx context.Context, id string) error {
|
|
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// ErrHasLiveChildren is returned by SoftDeleteCascade when the caller did not
|
|
// request cascade=true and the item has at least one undeleted descendant.
|
|
var ErrHasLiveChildren = errors.New("projax: item has live children — pass cascade=true to soft-delete the whole subtree")
|
|
|
|
// SoftDeleteCascade soft-deletes the item and, if cascade is true, every
|
|
// descendant (any row whose paths array contains a prefix matching this
|
|
// item's primary path). Without cascade, it refuses when there is at least
|
|
// one live descendant.
|
|
func (s *Store) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
|
|
it, err := s.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
primary := it.PrimaryPath()
|
|
// Children are any other live row that names this id in their parent_ids
|
|
// (direct children) or has a path with the primary path as a `.`-prefix
|
|
// (transitive descendants).
|
|
var childCount int
|
|
err = s.Pool.QueryRow(ctx, `
|
|
select count(*) from projax.items
|
|
where deleted_at is null
|
|
and id <> $1
|
|
and ($1::uuid = any(parent_ids)
|
|
or exists (select 1 from unnest(paths) p where p like $2 || '.%'))
|
|
`, id, primary).Scan(&childCount)
|
|
if err != nil {
|
|
return fmt.Errorf("count children: %w", err)
|
|
}
|
|
if childCount > 0 && !cascade {
|
|
return ErrHasLiveChildren
|
|
}
|
|
if childCount > 0 && cascade {
|
|
_, err := s.Pool.Exec(ctx, `
|
|
update projax.items set deleted_at = now()
|
|
where deleted_at is null
|
|
and ($1::uuid = any(parent_ids)
|
|
or exists (select 1 from unnest(paths) p where p like $2 || '.%'))`,
|
|
id, primary)
|
|
if err != nil {
|
|
return fmt.Errorf("cascade soft-delete: %w", err)
|
|
}
|
|
}
|
|
return s.SoftDelete(ctx, id)
|
|
}
|
|
|
|
// GetByPathOrSlug resolves a single item by either a dot path (any entry in
|
|
// paths) or a bare root slug. Slug-only inputs match items whose paths array
|
|
// contains exactly the slug (i.e. root items) as well as a fallback unique
|
|
// slug match — useful for MCP callers that don't know the path.
|
|
func (s *Store) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) {
|
|
key = sanitizeKey(key)
|
|
if key == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
row := s.Pool.QueryRow(ctx, `select `+itemsUnifiedCols+`
|
|
from projax.items_unified u
|
|
where ($1 = any(u.paths) or u.slug = $1)
|
|
order by case when $1 = any(u.paths) then 0 else 1 end
|
|
limit 1`, key)
|
|
it, err := scanItem(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return it, nil
|
|
}
|
|
|
|
// SearchFilters narrows ListByFilters. Each field is treated independently;
|
|
// the predicates AND together. Empty fields are no-ops.
|
|
type SearchFilters struct {
|
|
ParentPath string // any item whose paths array contains a path beginning with this prefix
|
|
Tags []string // ALL must be present on the item
|
|
Management []string // ALL must be present on the item
|
|
Kind []string // ANY must be present on the item
|
|
Status string // exact match (active|done|archived)
|
|
Q string // ILIKE prefix-match on title/slug/aliases/content_md
|
|
HasRepo *bool // if non-nil, item must (or must not) have a gitea-repo link
|
|
HasCalDAV *bool // if non-nil, item must (or must not) have a caldav-list link
|
|
Limit int // 0 → no limit
|
|
}
|
|
|
|
// ListByFilters returns items_unified rows matching all supplied predicates.
|
|
// Used by the MCP list_items tool.
|
|
func (s *Store) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) {
|
|
conds := []string{"true"}
|
|
args := []any{}
|
|
addArg := func(v any) string {
|
|
args = append(args, v)
|
|
return fmt.Sprintf("$%d", len(args))
|
|
}
|
|
if f.ParentPath != "" {
|
|
p := addArg(f.ParentPath)
|
|
// Path equals or starts with `<parent>.`
|
|
conds = append(conds, fmt.Sprintf("exists (select 1 from unnest(u.paths) pp where pp = %s or pp like %s || '.%%')", p, p))
|
|
}
|
|
if len(f.Tags) > 0 {
|
|
conds = append(conds, fmt.Sprintf("u.tags @> %s::text[]", addArg(f.Tags)))
|
|
}
|
|
if len(f.Management) > 0 {
|
|
conds = append(conds, fmt.Sprintf("u.management @> %s::text[]", addArg(f.Management)))
|
|
}
|
|
if len(f.Kind) > 0 {
|
|
conds = append(conds, fmt.Sprintf("u.kind && %s::text[]", addArg(f.Kind)))
|
|
}
|
|
if f.Status != "" {
|
|
conds = append(conds, fmt.Sprintf("u.status = %s", addArg(f.Status)))
|
|
}
|
|
if f.Q != "" {
|
|
q := addArg("%" + f.Q + "%")
|
|
conds = append(conds, fmt.Sprintf("(u.title ilike %s or u.slug ilike %s or u.content_md ilike %s or exists (select 1 from unnest(u.aliases) a where a ilike %s))", q, q, q, q))
|
|
}
|
|
if f.HasRepo != nil {
|
|
op := ""
|
|
if *f.HasRepo {
|
|
op = "exists"
|
|
} else {
|
|
op = "not exists"
|
|
}
|
|
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'gitea-repo')", op))
|
|
}
|
|
if f.HasCalDAV != nil {
|
|
op := ""
|
|
if *f.HasCalDAV {
|
|
op = "exists"
|
|
} else {
|
|
op = "not exists"
|
|
}
|
|
conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'caldav-list')", op))
|
|
}
|
|
q := `select ` + itemsUnifiedCols + ` from projax.items_unified u where ` + joinAnd(conds) + ` order by u.paths[1] nulls last, u.slug`
|
|
if f.Limit > 0 {
|
|
q += fmt.Sprintf(" limit %d", f.Limit)
|
|
}
|
|
rows, err := s.Pool.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// Search returns ranked items_unified rows matching the query. Match buckets:
|
|
// (0) exact slug, (1) title ILIKE prefix, (2) title contains, (3) alias hit,
|
|
// (4) content_md contains. Within each bucket rows are sorted by primary path.
|
|
func (s *Store) Search(ctx context.Context, q string, limit int) ([]*Item, error) {
|
|
q = sanitizeKey(q)
|
|
if q == "" {
|
|
return nil, nil
|
|
}
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
// Ranking is computed in SQL with a virtual `match_rank`, then we re-select
|
|
// just the canonical column set so scanItems handles the row.
|
|
sql := `with ranked as (
|
|
select u.*,
|
|
case
|
|
when u.slug = $1 then 0
|
|
when u.title ilike $1 || '%' then 1
|
|
when u.title ilike '%' || $1 || '%' then 2
|
|
when exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%') then 3
|
|
when u.content_md ilike '%' || $1 || '%' then 4
|
|
else 5
|
|
end as match_rank
|
|
from projax.items_unified u
|
|
where true
|
|
and (
|
|
u.slug = $1
|
|
or u.title ilike '%' || $1 || '%'
|
|
or u.content_md ilike '%' || $1 || '%'
|
|
or exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%')
|
|
)
|
|
)
|
|
select ` + itemsUnifiedCols + `
|
|
from ranked
|
|
order by match_rank, paths[1] nulls last, slug
|
|
limit $2`
|
|
rows, err := s.Pool.Query(ctx, sql, q, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanItems(rows)
|
|
}
|
|
|
|
// sanitizeKey trims and rejects NUL / control characters that the planner
|
|
// would otherwise have to deal with.
|
|
func sanitizeKey(s string) string {
|
|
s = trimSpace(s)
|
|
for _, r := range s {
|
|
if r == 0 || (r < 0x20 && r != '\t') {
|
|
return ""
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func trimSpace(s string) string {
|
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
|
|
s = s[1:]
|
|
}
|
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func joinAnd(parts []string) string {
|
|
out := ""
|
|
for i, p := range parts {
|
|
if i > 0 {
|
|
out += " and "
|
|
}
|
|
out += p
|
|
}
|
|
return out
|
|
}
|