Phase 5d slice B. createItemTool / updateItemTool stop encoding rejections
as `validation <kind>: <detail> [{json-blob}]` glued into .error.message
and instead return ValidationToolError(ve), which the JSON-RPC envelope
marshals as:
{ code: -32602,
message: "<kind>: <detail>",
data: { kind, path, detail } }
Clients introspect `.error.data.kind` directly — no JSON suffix to parse
out of the message. -32602 is the JSON-RPC "Invalid params" code, the
right semantic level for an itemwrite rejection.
mcp/tools.go:
- Replace itemWriteError with ValidationToolError. The legacy helper is
gone; four call sites (create_item × 2, update_item × 2) switch over
one-for-one.
mcp/mcp_test.go:
- Add TestToolsCallValidationError. Pins the wire shape: code=-32602,
message=`<kind>: <detail>` with no JSON suffix, and data carrying
{kind, path, detail}. Also asserts the rejection does NOT route
through result.isError — the slice A guarantee remains intact for
validation errors specifically.
- Import internal/itemwrite for the ValidationError fixture.
No test source edits to existing assertions — the prior tests don't
inspect the legacy `validation X: Y [{...}]` Msg shape, so behaviour
preservation holds without touching them. The new test is additive.
Live probe (post-deploy): POST `create_item` against projax.msbls.de/mcp/rpc
with slug='BAD.SLUG' returns `error.data.kind = "invalid-slug-format"`.
1251 lines
41 KiB
Go
1251 lines
41 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/m/projax/internal/aggregate"
|
|
"github.com/m/projax/internal/itemwrite"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// ValidationToolError promotes an *itemwrite.ValidationError into a typed
|
|
// *ToolError: code -32602 (Invalid params) per JSON-RPC convention, a
|
|
// clean "<kind>: <detail>" Msg, and Data carrying the structured
|
|
// {kind, path, detail} object MCP clients introspect via .error.data
|
|
// without parsing a JSON suffix out of the message.
|
|
func ValidationToolError(ve *itemwrite.ValidationError) *ToolError {
|
|
return &ToolError{
|
|
Code: codeInvalidParams,
|
|
Msg: fmt.Sprintf("%s: %s", ve.Kind, ve.Detail),
|
|
Data: map[string]any{
|
|
"kind": ve.Kind,
|
|
"path": ve.Path,
|
|
"detail": ve.Detail,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
|
|
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
|
|
type TimelineArgs struct {
|
|
From string `json:"from"` // YYYY-MM-DD, optional (default now-30d)
|
|
To string `json:"to"` // YYYY-MM-DD, optional (default now+90d)
|
|
Order string `json:"order"` // "asc" | "desc" (default desc)
|
|
Kinds []string `json:"kinds"` // subset of [todo,event,doc,creation]; empty = all
|
|
Tags []string `json:"tags"` // ALL must be present
|
|
Mgmt []string `json:"mgmt"` // ALL must be present
|
|
Has []string `json:"has"` // ALL ref-types present (caldav-list / gitea-repo)
|
|
Status []string `json:"status"` // ANY match (default ["active"]) — first value used for the store filter
|
|
Q string `json:"q"` // substring match against title/slug/aliases/content_md
|
|
IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays
|
|
}
|
|
|
|
// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server.
|
|
// All tools delegate to *store.Store directly so business logic is shared
|
|
// with the web UI — no duplication. The optional agg argument adds the
|
|
// timeline tool when non-nil (it needs the fan-out aggregator; passing nil
|
|
// keeps the rest of the toolset usable without aggregate deps).
|
|
func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) {
|
|
s.Register(Tool{
|
|
Name: "list_items",
|
|
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"parent_path": {"type": "string", "description": "Match items whose paths array contains a path beginning with this prefix"},
|
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "All tags must be present"},
|
|
"management": {"type": "array", "items": {"type": "string"}, "description": "All management modes must be present (e.g. ['mai'])"},
|
|
"kind": {"type": "array", "items": {"type": "string"}, "description": "Any of these kinds matches"},
|
|
"status": {"type": "string"},
|
|
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"},
|
|
"has_repo": {"type": "boolean"},
|
|
"has_caldav": {"type": "boolean"},
|
|
"public": {"type": "boolean", "description": "When true, returns only public items; false returns only private; absent returns all (Phase 4d)"},
|
|
"limit": {"type": "integer", "minimum": 0}
|
|
}
|
|
}`),
|
|
Handler: listItemsTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "get_item",
|
|
Description: "Fetch a single item by id, dot-path (e.g. 'dev.paliad'), or root slug. Multi-parent items resolve to the same row from any path.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string", "description": "uuid"},
|
|
"path": {"type": "string", "description": "Dot-path or slug"},
|
|
"include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"}
|
|
}
|
|
}`),
|
|
Handler: getItemTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "create_item",
|
|
Description: "Create a new projax item. parent_paths is a string[] — pass [] for a root, ['work'] for single-parent, ['work','dev'] for multi-parent.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["slug", "title"],
|
|
"properties": {
|
|
"slug": {"type": "string"},
|
|
"title": {"type": "string"},
|
|
"parent_paths": {"type": "array", "items": {"type": "string"}},
|
|
"kind": {"type": "array", "items": {"type": "string"}},
|
|
"tags": {"type": "array", "items": {"type": "string"}},
|
|
"management": {"type": "array", "items": {"type": "string"}},
|
|
"content_md": {"type": "string"},
|
|
"status": {"type": "string"},
|
|
"metadata": {"type": "object"}
|
|
}
|
|
}`),
|
|
Handler: createItemTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "update_item",
|
|
Description: "Partial update of an existing item. Pass any subset of title/slug/content_md/status/tags/management/parent_paths/pinned/archived, the Phase-4d public-listing fields (public, public_description, public_live_url, public_source_url, public_screenshots), or the Phase-4f timeline_exclude array. parent_paths replaces the full parent list.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
"title": {"type": "string"},
|
|
"slug": {"type": "string"},
|
|
"parent_paths": {"type": "array", "items": {"type": "string"}},
|
|
"content_md": {"type": "string"},
|
|
"status": {"type": "string"},
|
|
"pinned": {"type": "boolean"},
|
|
"archived": {"type": "boolean"},
|
|
"tags": {"type": "array", "items": {"type": "string"}},
|
|
"management": {"type": "array", "items": {"type": "string"}},
|
|
"public": {"type": "boolean"},
|
|
"public_description": {"type": "string"},
|
|
"public_live_url": {"type": "string"},
|
|
"public_source_url": {"type": "string"},
|
|
"public_screenshots": {"type": "array", "items": {"type": "string"}},
|
|
"timeline_exclude": {"type": "array", "items": {"type": "string", "enum": ["todos","events","docs","creation"]}, "description": "Phase 4f — kinds to hide from /timeline (per item)"}
|
|
}
|
|
}`),
|
|
Handler: updateItemTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "delete_item",
|
|
Description: "Soft-delete an item. Refuses on live descendants unless cascade=true.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
"cascade": {"type": "boolean", "description": "Soft-delete every descendant too"}
|
|
}
|
|
}`),
|
|
Handler: deleteItemTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "list_links",
|
|
Description: "List item_links attached to one item.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
"ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"}
|
|
}
|
|
}`),
|
|
Handler: listLinksTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "add_link",
|
|
Description: "Add an external item_link to an item (caldav-list / gitea-repo / document / note / url / …). Pass event_date=YYYY-MM-DD to anchor a dated artifact (PER day-granular).",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["ref_type", "ref_id"],
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
"ref_type": {"type": "string"},
|
|
"ref_id": {"type": "string"},
|
|
"rel": {"type": "string", "description": "Relation, default 'contains'"},
|
|
"note": {"type": "string"},
|
|
"event_date": {"type": "string", "description": "YYYY-MM-DD; day-granular anchor for PER-cited artifacts"},
|
|
"metadata": {"type": "object"}
|
|
}
|
|
}`),
|
|
Handler: addLinkTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "remove_link",
|
|
Description: "Delete an item_link by id.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["link_id"],
|
|
"properties": {"link_id": {"type": "string"}}
|
|
}`),
|
|
Handler: removeLinkTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "search",
|
|
Description: "Ranked substring search across title/slug/aliases/content_md. Buckets: exact-slug → title-prefix → title-contains → alias → content.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["query"],
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 200}
|
|
}
|
|
}`),
|
|
Handler: searchTool(st),
|
|
})
|
|
s.Register(Tool{
|
|
Name: "tree",
|
|
Description: "Return a nested tree of items. Multi-parent items appear under each ancestor branch.",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"root_path": {"type": "string", "description": "Optional subtree root; default returns the whole forest"},
|
|
"depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"}
|
|
}
|
|
}`),
|
|
Handler: treeTool(st),
|
|
})
|
|
if agg != nil {
|
|
s.Register(Tool{
|
|
Name: "timeline",
|
|
Description: "Chronological spine of dated content (VTODOs with DUE, VEVENTs, dated item_links, item-creation markers) braided into per-day groups. Same shape as projax's /timeline web view. All filters optional; defaults mirror the web page (past 30d through next 90d, desc order, all four kinds).",
|
|
InputSchema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string", "description": "YYYY-MM-DD inclusive lower bound; default now-30d"},
|
|
"to": {"type": "string", "description": "YYYY-MM-DD inclusive upper bound; default now+90d"},
|
|
"order": {"type": "string", "enum": ["asc","desc"], "description": "Day-group order; default desc"},
|
|
"kinds": {"type": "array", "items": {"type": "string", "enum": ["todo","event","doc","creation"]}, "description": "Narrow to a subset; empty = all four"},
|
|
"tags": {"type": "array", "items": {"type": "string"}, "description": "ALL tags must be present"},
|
|
"mgmt": {"type": "array", "items": {"type": "string"}, "description": "ALL management modes must be present"},
|
|
"has": {"type": "array", "items": {"type": "string"}, "description": "ALL link kinds present (e.g. ['caldav-list'])"},
|
|
"status": {"type": "array", "items": {"type": "string"}, "description": "First value used for the store-level filter; default ['active']"},
|
|
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
|
|
}
|
|
}`),
|
|
Handler: timelineTool(st, agg),
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- timeline ---
|
|
//
|
|
// The tool runs the timeline aggregation end-to-end inside mcp/: store
|
|
// filters → fan-out via *aggregate.Aggregator → row projection →
|
|
// BuildTimelineDays → serialised timelineView. Phase 5a slice D moved this
|
|
// off *web.Server.BuildTimelinePayloadFromArgs so mcp no longer points back
|
|
// at web/. Tree-filter dimensions (tags/mgmt/status/Q/has) are translated
|
|
// to store.SearchFilters; the MCP filter surface is slightly narrower than
|
|
// the web TreeFilter (single-value status, AND-management) — m's main MCP
|
|
// use cases (tag + status) round-trip identically.
|
|
|
|
const (
|
|
timelineDefaultPastDays = 30
|
|
timelineDefaultFutureDays = 90
|
|
)
|
|
|
|
func timelineTool(st *store.Store, agg *aggregate.Aggregator) ToolHandler {
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var args TimelineArgs
|
|
if err := parseInput(raw, &args); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
now := time.Now()
|
|
from, to, err := resolveTimelineWindow(args, now)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
order := "desc"
|
|
if args.Order == "asc" {
|
|
order = "asc"
|
|
}
|
|
kinds := resolveTimelineKinds(args.Kinds)
|
|
// Effective kinds is what we use for the timeline_exclude filter +
|
|
// the timelineView.kinds report. Empty input means "all four".
|
|
effectiveKinds := kinds
|
|
if len(effectiveKinds) == 0 {
|
|
effectiveKinds = []string{aggregate.KindTodo, aggregate.KindEvent, aggregate.KindDoc, aggregate.KindCreation}
|
|
}
|
|
items, err := resolveTimelineItems(ctx, st, args)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
if !args.IncludeExcluded {
|
|
items = filterByTimelineExclude(items, effectiveKinds)
|
|
}
|
|
hasCalDAVRefType := containsString(args.Has, aggregate.RefTypeCalDAV)
|
|
hasGitRefType := containsString(args.Has, aggregate.RefTypeGiteaRepo)
|
|
items, err = applyHasLinkFilters(ctx, st, items, hasCalDAVRefType, hasGitRefType)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
|
|
result := agg.All(ctx, items, aggregate.AllOpts{
|
|
Window: aggregate.Window{From: from, To: to},
|
|
Kinds: kinds,
|
|
})
|
|
rows := result.ToTimelineRows()
|
|
// Defensive narrowing for events: the aggregator passed `to` to
|
|
// CalDAV as TimeMax (exclusive) — but events occasionally arrive
|
|
// outside that bound when servers misinterpret RECURRENCE-ID. Strip
|
|
// them here so the MCP payload stays inside the documented window.
|
|
filtered := rows[:0]
|
|
for _, r := range rows {
|
|
if r.Date.Before(from) || !r.Date.Before(to) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, r)
|
|
}
|
|
days := aggregate.BuildTimelineDays(filtered, aggregate.BuildOpts{
|
|
Now: now,
|
|
Order: order,
|
|
FadeAfter: 30 * 24 * time.Hour,
|
|
})
|
|
totalRows := 0
|
|
for _, d := range days {
|
|
totalRows += len(d.Rows)
|
|
}
|
|
return buildTimelineView(days, from, to, order, effectiveKinds, totalRows, now), nil
|
|
}
|
|
}
|
|
|
|
func resolveTimelineWindow(args TimelineArgs, now time.Time) (time.Time, time.Time, error) {
|
|
from := startOfDay(now.AddDate(0, 0, -timelineDefaultPastDays))
|
|
to := startOfDay(now.AddDate(0, 0, timelineDefaultFutureDays))
|
|
if v := strings.TrimSpace(args.From); v != "" {
|
|
t, err := time.Parse("2006-01-02", v)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("from must be YYYY-MM-DD: %w", err)
|
|
}
|
|
from = startOfDay(t)
|
|
}
|
|
if v := strings.TrimSpace(args.To); v != "" {
|
|
t, err := time.Parse("2006-01-02", v)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("to must be YYYY-MM-DD: %w", err)
|
|
}
|
|
to = startOfDay(t).AddDate(0, 0, 1)
|
|
}
|
|
return from, to, nil
|
|
}
|
|
|
|
func resolveTimelineKinds(in []string) []string {
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
allowed := map[string]struct{}{
|
|
aggregate.KindTodo: {}, aggregate.KindEvent: {}, aggregate.KindDoc: {}, aggregate.KindCreation: {},
|
|
}
|
|
for _, k := range in {
|
|
k = strings.ToLower(strings.TrimSpace(k))
|
|
if _, ok := allowed[k]; !ok {
|
|
continue
|
|
}
|
|
if seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func resolveTimelineItems(ctx context.Context, st *store.Store, args TimelineArgs) ([]*store.Item, error) {
|
|
status := "active"
|
|
if len(args.Status) > 0 && strings.TrimSpace(args.Status[0]) != "" {
|
|
status = strings.TrimSpace(args.Status[0])
|
|
}
|
|
if status == "*" || status == "any" {
|
|
status = ""
|
|
}
|
|
return st.ListByFilters(ctx, store.SearchFilters{
|
|
Tags: trimList(args.Tags),
|
|
Management: trimList(args.Mgmt),
|
|
Status: status,
|
|
Q: strings.TrimSpace(args.Q),
|
|
})
|
|
}
|
|
|
|
func filterByTimelineExclude(items []*store.Item, kinds []string) []*store.Item {
|
|
if len(items) == 0 {
|
|
return items
|
|
}
|
|
out := items[:0:0]
|
|
for _, it := range items {
|
|
keep := false
|
|
for _, k := range kinds {
|
|
if !it.ExcludesTimelineKind(k) {
|
|
keep = true
|
|
break
|
|
}
|
|
}
|
|
if keep {
|
|
out = append(out, it)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyHasLinkFilters(ctx context.Context, st *store.Store, items []*store.Item, needCalDAV, needGitea bool) ([]*store.Item, error) {
|
|
if !needCalDAV && !needGitea {
|
|
return items, nil
|
|
}
|
|
hasCalDAV := map[string]struct{}{}
|
|
hasGitea := map[string]struct{}{}
|
|
if needCalDAV {
|
|
links, err := st.LinksByRefType(ctx, aggregate.RefTypeCalDAV)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, l := range links {
|
|
hasCalDAV[l.ItemID] = struct{}{}
|
|
}
|
|
}
|
|
if needGitea {
|
|
links, err := st.LinksByRefType(ctx, aggregate.RefTypeGiteaRepo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, l := range links {
|
|
hasGitea[l.ItemID] = struct{}{}
|
|
}
|
|
}
|
|
out := items[:0:0]
|
|
for _, it := range items {
|
|
if needCalDAV {
|
|
if _, ok := hasCalDAV[it.ID]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if needGitea {
|
|
if _, ok := hasGitea[it.ID]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func containsString(haystack []string, needle string) bool {
|
|
for _, s := range haystack {
|
|
if s == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func trimList(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func startOfDay(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
// timelineView mirrors web.TimelinePayload but stringifies times so JSON
|
|
// consumers don't have to know about Go's RFC 3339 format vs the YYYY-MM-DD
|
|
// flavour the web template uses.
|
|
type timelineView struct {
|
|
Days []timelineDayView `json:"days"`
|
|
From string `json:"from"` // YYYY-MM-DD inclusive
|
|
To string `json:"to"` // YYYY-MM-DD exclusive (matches internal bound)
|
|
ToInclusive string `json:"to_inclusive"` // YYYY-MM-DD inclusive (for display)
|
|
Order string `json:"order"`
|
|
Kinds []string `json:"kinds"`
|
|
TotalRows int `json:"total_rows"`
|
|
BuiltAt string `json:"built_at"`
|
|
}
|
|
|
|
type timelineDayView struct {
|
|
Date string `json:"date"` // YYYY-MM-DD
|
|
Label string `json:"label"`
|
|
Sticky string `json:"sticky"`
|
|
Rows []timelineRowView `json:"rows"`
|
|
}
|
|
|
|
type timelineRowView struct {
|
|
Kind string `json:"kind"`
|
|
ItemPath string `json:"item_path"`
|
|
ItemTitle string `json:"item_title"`
|
|
FarFuture bool `json:"far_future,omitempty"`
|
|
Todo *timelineTodoView `json:"todo,omitempty"`
|
|
Event *timelineEventView `json:"event,omitempty"`
|
|
Link *linkView `json:"link,omitempty"`
|
|
PER string `json:"per,omitempty"`
|
|
}
|
|
|
|
type timelineTodoView struct {
|
|
UID string `json:"uid"`
|
|
CalendarURL string `json:"calendar_url"`
|
|
Summary string `json:"summary"`
|
|
Status string `json:"status"`
|
|
Due string `json:"due,omitempty"` // YYYY-MM-DD or ISO when timed
|
|
Priority int `json:"priority,omitempty"`
|
|
}
|
|
|
|
type timelineEventView struct {
|
|
UID string `json:"uid"`
|
|
Summary string `json:"summary"`
|
|
Start string `json:"start"` // ISO-8601 local
|
|
StartLabel string `json:"start_label"`
|
|
End string `json:"end,omitempty"`
|
|
AllDay bool `json:"all_day,omitempty"`
|
|
Location string `json:"location,omitempty"`
|
|
Recurring bool `json:"recurring,omitempty"`
|
|
DurationHint string `json:"duration_hint,omitempty"`
|
|
}
|
|
|
|
func buildTimelineView(days []aggregate.TimelineDay, from, to time.Time, order string, kinds []string, totalRows int, builtAt time.Time) timelineView {
|
|
if kinds == nil {
|
|
kinds = []string{}
|
|
}
|
|
out := timelineView{
|
|
From: from.Format("2006-01-02"),
|
|
To: to.Format("2006-01-02"),
|
|
ToInclusive: to.AddDate(0, 0, -1).Format("2006-01-02"),
|
|
Order: order,
|
|
Kinds: kinds,
|
|
TotalRows: totalRows,
|
|
BuiltAt: builtAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
out.Days = make([]timelineDayView, 0, len(days))
|
|
for _, d := range days {
|
|
dv := timelineDayView{
|
|
Date: d.DateKey,
|
|
Label: d.Label,
|
|
Sticky: d.Sticky,
|
|
}
|
|
dv.Rows = make([]timelineRowView, 0, len(d.Rows))
|
|
for _, r := range d.Rows {
|
|
rv := timelineRowView{
|
|
Kind: r.Kind,
|
|
ItemPath: r.ItemPath,
|
|
FarFuture: r.FarFuture,
|
|
}
|
|
if r.Item != nil {
|
|
rv.ItemTitle = r.Item.Title
|
|
}
|
|
switch r.Kind {
|
|
case "todo":
|
|
tv := timelineTodoView{
|
|
UID: r.Todo.UID,
|
|
CalendarURL: r.CalendarURL,
|
|
Summary: r.Todo.Summary,
|
|
Status: r.Todo.Status,
|
|
Priority: r.Todo.Priority,
|
|
}
|
|
if r.Todo.Due != nil {
|
|
if r.Todo.Due.Hour() == 0 && r.Todo.Due.Minute() == 0 && r.Todo.Due.Second() == 0 {
|
|
tv.Due = r.Todo.Due.Format("2006-01-02")
|
|
} else {
|
|
tv.Due = r.Todo.Due.UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
}
|
|
rv.Todo = &tv
|
|
case "event":
|
|
ev := timelineEventView{
|
|
UID: r.Event.UID,
|
|
Summary: r.Event.Summary,
|
|
Start: r.Event.Start.UTC().Format("2006-01-02T15:04:05Z"),
|
|
StartLabel: r.StartLabel,
|
|
AllDay: r.Event.AllDay,
|
|
Location: r.Event.Location,
|
|
Recurring: r.Event.Recurring,
|
|
DurationHint: r.DurationHint,
|
|
}
|
|
if !r.Event.End.IsZero() {
|
|
ev.End = r.Event.End.UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
rv.Event = &ev
|
|
case "doc":
|
|
if r.Link != nil {
|
|
lv := toLinkView(r.Link)
|
|
rv.Link = &lv
|
|
}
|
|
rv.PER = r.PER
|
|
case "creation":
|
|
// no extra payload — ItemPath + ItemTitle cover the row
|
|
}
|
|
dv.Rows = append(dv.Rows, rv)
|
|
}
|
|
out.Days = append(out.Days, dv)
|
|
}
|
|
return out
|
|
}
|
|
|
|
|
|
// itemView is the JSON shape returned to MCP clients. We hand-roll it so the
|
|
// field names stay snake_case and the *time.Time / *string nullability
|
|
// renders as JSON null instead of being skipped (omitempty would hide them).
|
|
type itemView struct {
|
|
ID string `json:"id"`
|
|
Kind []string `json:"kind"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
Paths []string `json:"paths"`
|
|
ParentIDs []string `json:"parent_ids"`
|
|
ContentMD string `json:"content_md"`
|
|
Aliases []string `json:"aliases"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
Status string `json:"status"`
|
|
Pinned bool `json:"pinned"`
|
|
Archived bool `json:"archived"`
|
|
StartTime any `json:"start_time"`
|
|
EndTime any `json:"end_time"`
|
|
Source string `json:"source"`
|
|
SourceRefID any `json:"source_ref_id"`
|
|
Tags []string `json:"tags"`
|
|
Management []string `json:"management"`
|
|
// Phase 4d public-listing fields. Always emitted (even when public=false)
|
|
// so consumers can show "this would be public if you flipped the toggle"
|
|
// previews without a second round-trip.
|
|
Public bool `json:"public"`
|
|
PublicDescription string `json:"public_description"`
|
|
PublicLiveURL string `json:"public_live_url"`
|
|
PublicSourceURL string `json:"public_source_url"`
|
|
PublicScreenshots []string `json:"public_screenshots"`
|
|
// Phase 4f
|
|
TimelineExclude []string `json:"timeline_exclude"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Links []linkView `json:"links,omitempty"`
|
|
}
|
|
|
|
type linkView struct {
|
|
ID string `json:"id"`
|
|
ItemID string `json:"item_id"`
|
|
RefType string `json:"ref_type"`
|
|
RefID string `json:"ref_id"`
|
|
Rel string `json:"rel"`
|
|
Note any `json:"note"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
CreatedAt string `json:"created_at"`
|
|
EventDate any `json:"event_date"`
|
|
}
|
|
|
|
func toItemView(it *store.Item) itemView {
|
|
v := itemView{
|
|
ID: it.ID,
|
|
Kind: sliceOr(it.Kind, []string{}),
|
|
Title: it.Title,
|
|
Slug: it.Slug,
|
|
Paths: sliceOr(it.Paths, []string{}),
|
|
ParentIDs: sliceOr(it.ParentIDs, []string{}),
|
|
ContentMD: it.ContentMD,
|
|
Aliases: sliceOr(it.Aliases, []string{}),
|
|
Metadata: mapOr(it.Metadata),
|
|
Status: it.Status,
|
|
Pinned: it.Pinned,
|
|
Archived: it.Archived,
|
|
Source: it.Source,
|
|
Tags: sliceOr(it.Tags, []string{}),
|
|
Management: sliceOr(it.Management, []string{}),
|
|
Public: it.Public,
|
|
PublicDescription: it.PublicDescription,
|
|
PublicLiveURL: it.PublicLiveURL,
|
|
PublicSourceURL: it.PublicSourceURL,
|
|
PublicScreenshots: sliceOr(it.PublicScreenshots, []string{}),
|
|
TimelineExclude: sliceOr(it.TimelineExclude, []string{}),
|
|
CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedAt: it.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if it.StartTime != nil {
|
|
v.StartTime = it.StartTime.UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
if it.EndTime != nil {
|
|
v.EndTime = it.EndTime.UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
if it.SourceRefID != nil {
|
|
v.SourceRefID = *it.SourceRefID
|
|
}
|
|
return v
|
|
}
|
|
|
|
func toLinkView(l *store.ItemLink) linkView {
|
|
v := linkView{
|
|
ID: l.ID,
|
|
ItemID: l.ItemID,
|
|
RefType: l.RefType,
|
|
RefID: l.RefID,
|
|
Rel: l.Rel,
|
|
Metadata: mapOr(l.Metadata),
|
|
CreatedAt: l.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if l.Note != nil {
|
|
v.Note = *l.Note
|
|
}
|
|
if l.EventDate != nil {
|
|
v.EventDate = l.EventDate.UTC().Format("2006-01-02")
|
|
}
|
|
return v
|
|
}
|
|
|
|
func sliceOr[T any](v []T, fallback []T) []T {
|
|
if v == nil {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
func mapOr(v map[string]any) map[string]any {
|
|
if v == nil {
|
|
return map[string]any{}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// resolveItem turns an id-or-path argument pair into a concrete *store.Item.
|
|
func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) {
|
|
id = strings.TrimSpace(id)
|
|
path = strings.TrimSpace(path)
|
|
if id != "" {
|
|
return st.GetByID(ctx, id)
|
|
}
|
|
if path != "" {
|
|
return st.GetByPathOrSlug(ctx, path)
|
|
}
|
|
return nil, errors.New("either id or path is required")
|
|
}
|
|
|
|
func parseInput[T any](raw json.RawMessage, dst *T) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(raw, dst)
|
|
}
|
|
|
|
// --- list_items ---
|
|
|
|
func listItemsTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
ParentPath string `json:"parent_path"`
|
|
Tags []string `json:"tags"`
|
|
Management []string `json:"management"`
|
|
Kind []string `json:"kind"`
|
|
Status string `json:"status"`
|
|
Q string `json:"q"`
|
|
HasRepo *bool `json:"has_repo"`
|
|
HasCalDAV *bool `json:"has_caldav"`
|
|
Public *bool `json:"public"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
items, err := st.ListByFilters(ctx, store.SearchFilters{
|
|
ParentPath: in.ParentPath,
|
|
Tags: in.Tags,
|
|
Management: in.Management,
|
|
Kind: in.Kind,
|
|
Status: in.Status,
|
|
Q: in.Q,
|
|
HasRepo: in.HasRepo,
|
|
HasCalDAV: in.HasCalDAV,
|
|
Public: in.Public,
|
|
Limit: in.Limit,
|
|
})
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
views := make([]itemView, 0, len(items))
|
|
for _, it := range items {
|
|
views = append(views, toItemView(it))
|
|
}
|
|
return map[string]any{"items": views, "count": len(views)}, nil
|
|
}
|
|
}
|
|
|
|
// --- get_item ---
|
|
|
|
func getItemTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
IncludeLinks *bool `json:"include_links"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
view := toItemView(it)
|
|
include := true
|
|
if in.IncludeLinks != nil {
|
|
include = *in.IncludeLinks
|
|
}
|
|
if include {
|
|
links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types
|
|
// LinksByType filters by ref_type — empty would return nothing. So
|
|
// we explicitly list_all by fanning across the known types.
|
|
_ = err
|
|
links = nil
|
|
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
|
|
ll, err := st.LinksByType(ctx, it.ID, t)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
links = append(links, ll...)
|
|
}
|
|
views := make([]linkView, 0, len(links))
|
|
for _, l := range links {
|
|
views = append(views, toLinkView(l))
|
|
}
|
|
view.Links = views
|
|
}
|
|
return view, nil
|
|
}
|
|
}
|
|
|
|
// --- create_item ---
|
|
|
|
func createItemTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
ParentPaths []string `json:"parent_paths"`
|
|
Kind []string `json:"kind"`
|
|
Tags []string `json:"tags"`
|
|
Management []string `json:"management"`
|
|
ContentMD string `json:"content_md"`
|
|
Status string `json:"status"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
kind := in.Kind
|
|
if len(kind) == 0 {
|
|
kind = []string{"project"}
|
|
}
|
|
// Phase 5c: validate up-front so MCP clients get typed
|
|
// {kind, path, detail} on rejection instead of an opaque pgErr.
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
|
|
}); ve != nil {
|
|
return nil, ValidationToolError(ve)
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{
|
|
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
|
|
}); ve != nil {
|
|
return nil, ValidationToolError(ve)
|
|
}
|
|
it, err := st.Create(ctx, store.CreateInput{
|
|
Kind: kind,
|
|
Title: in.Title,
|
|
Slug: in.Slug,
|
|
ParentIDs: parentIDs,
|
|
ContentMD: in.ContentMD,
|
|
Status: in.Status,
|
|
Tags: in.Tags,
|
|
Management: in.Management,
|
|
Metadata: in.Metadata,
|
|
})
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
return toItemView(it), nil
|
|
}
|
|
}
|
|
|
|
func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) {
|
|
out := make([]string, 0, len(paths))
|
|
for _, p := range paths {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
it, err := st.GetByPathOrSlug(ctx, p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parent path %q: %w", p, err)
|
|
}
|
|
out = append(out, it.ID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// --- update_item ---
|
|
|
|
func updateItemTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
Title *string `json:"title"`
|
|
Slug *string `json:"slug"`
|
|
ParentPaths *[]string `json:"parent_paths"`
|
|
ContentMD *string `json:"content_md"`
|
|
Status *string `json:"status"`
|
|
Pinned *bool `json:"pinned"`
|
|
Archived *bool `json:"archived"`
|
|
Tags *[]string `json:"tags"`
|
|
Management *[]string `json:"management"`
|
|
Public *bool `json:"public"`
|
|
PublicDescription *string `json:"public_description"`
|
|
PublicLiveURL *string `json:"public_live_url"`
|
|
PublicSourceURL *string `json:"public_source_url"`
|
|
PublicScreenshots *[]string `json:"public_screenshots"`
|
|
TimelineExclude *[]string `json:"timeline_exclude"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
patch := store.UpdateInput{
|
|
Title: it.Title,
|
|
Slug: it.Slug,
|
|
ParentIDs: it.ParentIDs,
|
|
ContentMD: it.ContentMD,
|
|
Status: it.Status,
|
|
Pinned: it.Pinned,
|
|
Archived: it.Archived,
|
|
Tags: it.Tags,
|
|
Management: it.Management,
|
|
Public: it.Public,
|
|
PublicDescription: it.PublicDescription,
|
|
PublicLiveURL: it.PublicLiveURL,
|
|
PublicSourceURL: it.PublicSourceURL,
|
|
PublicScreenshots: it.PublicScreenshots,
|
|
TimelineExclude: it.TimelineExclude,
|
|
}
|
|
if in.Title != nil {
|
|
patch.Title = *in.Title
|
|
}
|
|
if in.Slug != nil {
|
|
patch.Slug = *in.Slug
|
|
}
|
|
if in.ContentMD != nil {
|
|
patch.ContentMD = *in.ContentMD
|
|
}
|
|
if in.Status != nil {
|
|
patch.Status = *in.Status
|
|
}
|
|
if in.Pinned != nil {
|
|
patch.Pinned = *in.Pinned
|
|
}
|
|
if in.Archived != nil {
|
|
patch.Archived = *in.Archived
|
|
}
|
|
if in.Tags != nil {
|
|
patch.Tags = *in.Tags
|
|
}
|
|
if in.Management != nil {
|
|
patch.Management = *in.Management
|
|
}
|
|
if in.Public != nil {
|
|
patch.Public = *in.Public
|
|
}
|
|
if in.PublicDescription != nil {
|
|
patch.PublicDescription = *in.PublicDescription
|
|
}
|
|
if in.PublicLiveURL != nil {
|
|
patch.PublicLiveURL = *in.PublicLiveURL
|
|
}
|
|
if in.PublicSourceURL != nil {
|
|
patch.PublicSourceURL = *in.PublicSourceURL
|
|
}
|
|
if in.PublicScreenshots != nil {
|
|
patch.PublicScreenshots = *in.PublicScreenshots
|
|
}
|
|
if in.TimelineExclude != nil {
|
|
// Whitelist values so a stray entry doesn't poison the array. Same
|
|
// allowlist as parseTimelineExcludeList in web/.
|
|
allowed := map[string]struct{}{"todos": {}, "events": {}, "docs": {}, "creation": {}}
|
|
out := make([]string, 0, len(*in.TimelineExclude))
|
|
seen := map[string]struct{}{}
|
|
for _, v := range *in.TimelineExclude {
|
|
if _, ok := allowed[v]; !ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[v]; dup {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
patch.TimelineExclude = out
|
|
}
|
|
if in.ParentPaths != nil {
|
|
pids, err := resolveParentPaths(ctx, st, *in.ParentPaths)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
patch.ParentIDs = pids
|
|
}
|
|
// Phase 5c: pre-validate the patched item so MCP clients see typed
|
|
// {kind, path, detail} rejection instead of an opaque pgErr.
|
|
validateIn := itemwrite.Input{
|
|
ID: it.ID,
|
|
Title: patch.Title,
|
|
Slug: patch.Slug,
|
|
Status: patch.Status,
|
|
ParentIDs: patch.ParentIDs,
|
|
Path: it.PrimaryPath(),
|
|
}
|
|
if ve := itemwrite.ValidateFormat(validateIn); ve != nil {
|
|
return nil, ValidationToolError(ve)
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil {
|
|
return nil, ValidationToolError(ve)
|
|
}
|
|
updated, err := st.Update(ctx, it.ID, patch)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
return toItemView(updated), nil
|
|
}
|
|
}
|
|
|
|
// --- delete_item ---
|
|
|
|
func deleteItemTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
Cascade bool `json:"cascade"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil
|
|
}
|
|
}
|
|
|
|
// --- list_links ---
|
|
|
|
func listLinksTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
RefType string `json:"ref_type"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
var links []*store.ItemLink
|
|
if in.RefType != "" {
|
|
links, err = st.LinksByType(ctx, it.ID, in.RefType)
|
|
} else {
|
|
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
|
|
ll, lerr := st.LinksByType(ctx, it.ID, t)
|
|
if lerr != nil {
|
|
continue
|
|
}
|
|
links = append(links, ll...)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
views := make([]linkView, 0, len(links))
|
|
for _, l := range links {
|
|
views = append(views, toLinkView(l))
|
|
}
|
|
return map[string]any{"links": views, "count": len(views)}, nil
|
|
}
|
|
}
|
|
|
|
// --- add_link / remove_link ---
|
|
|
|
func addLinkTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
RefType string `json:"ref_type"`
|
|
RefID string `json:"ref_id"`
|
|
Rel string `json:"rel"`
|
|
Note string `json:"note"`
|
|
EventDate string `json:"event_date"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
if in.RefType == "" || in.RefID == "" {
|
|
return nil, &ToolError{Code: codeInternalError, Msg: "ref_type and ref_id are required"}
|
|
}
|
|
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
md := in.Metadata
|
|
if md == nil {
|
|
md = map[string]any{}
|
|
}
|
|
var notePtr *string
|
|
if in.Note != "" {
|
|
n := in.Note
|
|
notePtr = &n
|
|
}
|
|
var datePtr *time.Time
|
|
if strings.TrimSpace(in.EventDate) != "" {
|
|
t, err := time.Parse("2006-01-02", strings.TrimSpace(in.EventDate))
|
|
if err != nil {
|
|
return nil, InternalError(fmt.Errorf("event_date must be YYYY-MM-DD: %w", err))
|
|
}
|
|
datePtr = &t
|
|
}
|
|
link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
return toLinkView(link), nil
|
|
}
|
|
}
|
|
|
|
func removeLinkTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
LinkID string `json:"link_id"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
if in.LinkID == "" {
|
|
return nil, &ToolError{Code: codeInternalError, Msg: "link_id is required"}
|
|
}
|
|
if err := st.DeleteLink(ctx, in.LinkID); err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
return map[string]any{"deleted": in.LinkID}, nil
|
|
}
|
|
}
|
|
|
|
// --- search ---
|
|
|
|
func searchTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
if in.Query == "" {
|
|
return nil, &ToolError{Code: codeInternalError, Msg: "query is required"}
|
|
}
|
|
items, err := st.Search(ctx, in.Query, in.Limit)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
views := make([]itemView, 0, len(items))
|
|
for _, it := range items {
|
|
views = append(views, toItemView(it))
|
|
}
|
|
return map[string]any{"items": views, "count": len(views), "query": in.Query}, nil
|
|
}
|
|
}
|
|
|
|
// --- tree ---
|
|
|
|
type treeNode struct {
|
|
Item itemView `json:"item"`
|
|
Path string `json:"path"` // the path under which this node appears in the tree
|
|
Children []*treeNode `json:"children"`
|
|
}
|
|
|
|
func treeTool(st *store.Store) ToolHandler {
|
|
type input struct {
|
|
RootPath string `json:"root_path"`
|
|
Depth int `json:"depth"`
|
|
}
|
|
return func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
|
|
var in input
|
|
if err := parseInput(raw, &in); err != nil {
|
|
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
|
}
|
|
items, err := st.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
// Build adjacency by parent id (the same row appears once per parent).
|
|
byID := map[string]*store.Item{}
|
|
childrenByParent := map[string][]*store.Item{}
|
|
var roots []*store.Item
|
|
for _, it := range items {
|
|
byID[it.ID] = it
|
|
if len(it.ParentIDs) == 0 {
|
|
roots = append(roots, it)
|
|
continue
|
|
}
|
|
for _, pid := range it.ParentIDs {
|
|
childrenByParent[pid] = append(childrenByParent[pid], it)
|
|
}
|
|
}
|
|
var build func(it *store.Item, path string, depth int) *treeNode
|
|
build = func(it *store.Item, path string, depth int) *treeNode {
|
|
n := &treeNode{Item: toItemView(it), Path: path}
|
|
if in.Depth > 0 && depth >= in.Depth {
|
|
return n
|
|
}
|
|
for _, c := range childrenByParent[it.ID] {
|
|
n.Children = append(n.Children, build(c, path+"."+c.Slug, depth+1))
|
|
}
|
|
return n
|
|
}
|
|
var out []*treeNode
|
|
if in.RootPath != "" {
|
|
root, err := st.GetByPathOrSlug(ctx, in.RootPath)
|
|
if err != nil {
|
|
return nil, InternalError(err)
|
|
}
|
|
out = append(out, build(root, in.RootPath, 0))
|
|
} else {
|
|
for _, r := range roots {
|
|
out = append(out, build(r, r.Slug, 0))
|
|
}
|
|
}
|
|
return map[string]any{"tree": out, "roots": len(out)}, nil
|
|
}
|
|
}
|