Files
projax/mcp/tools.go
mAi 0bea9c1ba4 feat(phase 4f): per-item timeline_exclude flag (hide noise from /timeline)
m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.

## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
  toggle himself via /admin/bulk or the detail-page form.

## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
  before fanning out — items with the kind in their exclude array are
  dropped entirely (no CalDAV call wasted on excluded sources). Doc and
  creation rows check the per-item flag inline. `?include_excluded=1`
  (URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
  ("todo") or plural ("todos") to bridge the kind-constant / persisted-
  value naming choice — see comment for the why.

## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
  checkboxes (todos / events / docs / creation) and helper text. Open by
  default when any kind is excluded, so m can see at a glance what's
  hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
  and "Re-include on timeline" — the other three kinds stay editable
  per-item only per the task brief (most common use case is just todos).

## MCP
- update_item accepts timeline_exclude as a partial-update field with an
  enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
  can render the toggle state without a second round-trip.

## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances

## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.

## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
  should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
  via /admin/bulk after the deploy — schema change stays behaviour-neutral)
2026-05-17 19:28:49 +02:00

997 lines
32 KiB
Go

package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/m/projax/store"
"github.com/m/projax/web"
)
// TimelineBuilder is the subset of *web.Server the MCP timeline tool needs.
// Kept as an interface so tests can drive the registration without spinning
// up a full web.Server (the existing mcp tests pass nil — the timeline tool
// is simply not registered in that case).
type TimelineBuilder interface {
BuildTimelinePayloadFromArgs(ctx context.Context, args web.TimelineArgs) (*web.TimelinePayload, error)
}
// 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 tl argument adds the
// timeline tool when non-nil (it needs the web aggregation surface that
// fans out across CalDAV; passing nil keeps the rest of the toolset usable
// without web/ deps).
func RegisterProjaxTools(s *Server, st *store.Store, tl TimelineBuilder) {
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 tl != 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": "Tree-filter: ALL tags must be present"},
"mgmt": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ANY of these management modes matches"},
"has": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ALL link kinds present (e.g. ['caldav-list'])"},
"status": {"type": "array", "items": {"type": "string"}, "description": "Tree-filter: ANY status matches; default ['active']"},
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
}
}`),
Handler: timelineTool(tl),
})
}
}
// --- timeline ---
//
// The tool wraps web.*Server.BuildTimelinePayloadFromArgs. Output shape
// mirrors the web template's data structure with one small adaptation:
// time.Time values are serialised as ISO-8601 strings so the JSON-RPC
// envelope stays language-agnostic (the PWA's TypeScript client decodes
// these to Date via the same convention the existing list_items / get_item
// tools use).
func timelineTool(tl TimelineBuilder) ToolHandler {
return func(ctx context.Context, raw json.RawMessage) (any, error) {
var args web.TimelineArgs
if err := parseInput(raw, &args); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
payload, err := tl.BuildTimelinePayloadFromArgs(ctx, args)
if err != nil {
return nil, err
}
return toTimelineView(payload), nil
}
}
// 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 toTimelineView(p *web.TimelinePayload) timelineView {
out := timelineView{
From: p.From.Format("2006-01-02"),
To: p.To.Format("2006-01-02"),
ToInclusive: p.ToInclusive.Format("2006-01-02"),
Order: p.Order,
Kinds: sliceOr(p.Kinds, []string{}),
TotalRows: p.TotalRows,
BuiltAt: p.BuiltAt.UTC().Format("2006-01-02T15:04:05Z"),
}
out.Days = make([]timelineDayView, 0, len(p.Days))
for _, d := range p.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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, 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, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
if err != nil {
return nil, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
if in.Slug == "" || in.Title == "" {
return nil, errors.New("slug and title are required")
}
parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths)
if err != nil {
return nil, err
}
kind := in.Kind
if len(kind) == 0 {
kind = []string{"project"}
}
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, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
if err != nil {
return nil, 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, err
}
patch.ParentIDs = pids
}
updated, err := st.Update(ctx, it.ID, patch)
if err != nil {
return nil, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
if err != nil {
return nil, err
}
if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
return nil, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
if err != nil {
return nil, 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, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
if in.RefType == "" || in.RefID == "" {
return nil, errors.New("ref_type and ref_id are required")
}
it, err := resolveItem(ctx, st, in.ID, in.Path)
if err != nil {
return nil, 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, 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, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
if in.LinkID == "" {
return nil, errors.New("link_id is required")
}
if err := st.DeleteLink(ctx, in.LinkID); err != nil {
return nil, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
if in.Query == "" {
return nil, errors.New("query is required")
}
items, err := st.Search(ctx, in.Query, in.Limit)
if err != nil {
return nil, 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, error) {
var in input
if err := parseInput(raw, &in); err != nil {
return nil, fmt.Errorf("bad params: %w", err)
}
items, err := st.ListAll(ctx)
if err != nil {
return nil, 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, 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
}
}