MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).
Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.
Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
790 lines
27 KiB
Go
790 lines
27 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/sandrodach/mindsift/internal/auth"
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
"github.com/sandrodach/mindsift/internal/storage"
|
|
)
|
|
|
|
// MCP (Model Context Protocol) endpoint for AI agents: Streamable HTTP, stateless, JSON-RPC 2.0 over
|
|
// POST. Authentication runs through authMiddleware (API key with agent scopes or a session); every
|
|
// tool checks the scope it needs (mcpToolAllowed). There is no server-to-client stream (GET answers
|
|
// 405), so each request is one JSON response.
|
|
|
|
const mcpPath = "/api/v1/mcp"
|
|
|
|
// Version of the tool set, not of MindSift (the binary does not know its release)
|
|
const mcpServerVersion = "1"
|
|
|
|
// Protocol versions this server speaks; the newest is the default
|
|
var mcpProtocolVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
|
|
|
|
type jsonrpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
type jsonrpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type jsonrpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error *jsonrpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
const (
|
|
rpcParseError = -32700
|
|
rpcInvalidRequest = -32600
|
|
rpcMethodNotFound = -32601
|
|
rpcInvalidParams = -32602
|
|
)
|
|
|
|
// mcpTool is one callable tool: the scope an API key needs for it and the function doing the work.
|
|
// run returns the structured result; an error becomes a tool error (isError) the agent can read.
|
|
type mcpTool struct {
|
|
Name string
|
|
Description string
|
|
Scope string
|
|
// AltScope: a narrower right that allows this tool too (e.g. "create tasks" for create_task)
|
|
AltScope string
|
|
Schema map[string]interface{}
|
|
run func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error)
|
|
}
|
|
|
|
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
|
|
// read scope (its results are filtered to those scopes).
|
|
func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
|
|
if !isKey {
|
|
return true
|
|
}
|
|
if tool.Scope == "" {
|
|
return true
|
|
}
|
|
has := func(sc string) bool {
|
|
for _, have := range scopes {
|
|
if have == sc {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if tool.Name == "search" {
|
|
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
|
|
}
|
|
return has(tool.Scope) || (tool.AltScope != "" && has(tool.AltScope))
|
|
}
|
|
|
|
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
scopes, isKey := keyScopes(r.Context())
|
|
// Changes are recorded as made via MCP, with the key if there is one (a login session has none)
|
|
actor := actorFrom(r.Context())
|
|
if actor == nil {
|
|
actor = &apiActor{Via: storage.OriginMCP}
|
|
}
|
|
|
|
var raw json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
|
writeRPC(w, nil, nil, &jsonrpcError{rpcParseError, "Ungültiges JSON"})
|
|
return
|
|
}
|
|
trimmed := strings.TrimSpace(string(raw))
|
|
if strings.HasPrefix(trimmed, "[") {
|
|
writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Batch-Anfragen werden nicht unterstützt"})
|
|
return
|
|
}
|
|
var req jsonrpcRequest
|
|
if err := json.Unmarshal(raw, &req); err != nil || req.Method == "" {
|
|
writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Ungültige JSON-RPC-Anfrage"})
|
|
return
|
|
}
|
|
// Notifications (no id) are acknowledged without a body
|
|
if len(req.ID) == 0 || string(req.ID) == "null" {
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
|
|
switch req.Method {
|
|
case "initialize":
|
|
var p struct {
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
}
|
|
_ = json.Unmarshal(req.Params, &p)
|
|
version := mcpProtocolVersions[0]
|
|
for _, v := range mcpProtocolVersions {
|
|
if v == p.ProtocolVersion {
|
|
version = v
|
|
}
|
|
}
|
|
writeRPC(w, req.ID, map[string]interface{}{
|
|
"protocolVersion": version,
|
|
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
|
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
|
|
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
|
|
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
|
|
"lists and check_list_item to tick items. IDs come from the list tools; never guess them. " +
|
|
"Entries you create or change show the user a badge with the name of your API key.",
|
|
}, nil)
|
|
case "ping":
|
|
writeRPC(w, req.ID, map[string]interface{}{}, nil)
|
|
case "tools/list":
|
|
tools := []map[string]interface{}{}
|
|
for _, t := range mcpTools {
|
|
if !mcpToolAllowed(scopes, isKey, t) {
|
|
continue
|
|
}
|
|
tools = append(tools, map[string]interface{}{
|
|
"name": t.Name,
|
|
"description": t.Description,
|
|
"inputSchema": t.Schema,
|
|
})
|
|
}
|
|
writeRPC(w, req.ID, map[string]interface{}{"tools": tools}, nil)
|
|
case "tools/call":
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(req.Params, &p); err != nil || p.Name == "" {
|
|
writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Parameter 'name' fehlt"})
|
|
return
|
|
}
|
|
tool, ok := mcpToolByName(p.Name)
|
|
if !ok {
|
|
writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Unbekanntes Tool: " + p.Name})
|
|
return
|
|
}
|
|
if !mcpToolAllowed(scopes, isKey, tool) {
|
|
writeRPC(w, req.ID, toolError("Der API-Key hat keine Berechtigung für "+tool.Name+" (Scope "+tool.Scope+" fehlt)"), nil)
|
|
return
|
|
}
|
|
if len(p.Arguments) == 0 {
|
|
p.Arguments = json.RawMessage("{}")
|
|
}
|
|
result, err := tool.run(s, userID, actor, p.Arguments)
|
|
if err != nil {
|
|
writeRPC(w, req.ID, toolError(err.Error()), nil)
|
|
return
|
|
}
|
|
if tool.Name == "search" && isKey {
|
|
result = filterSearchForScopes(result, scopes)
|
|
}
|
|
writeRPC(w, req.ID, toolResult(result), nil)
|
|
default:
|
|
writeRPC(w, req.ID, nil, &jsonrpcError{rpcMethodNotFound, "Methode nicht unterstützt: " + req.Method})
|
|
}
|
|
}
|
|
|
|
// handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to
|
|
// end (DELETE).
|
|
func handleMCPNoStream(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Allow", "POST")
|
|
writeError(w, http.StatusMethodNotAllowed, "Der MCP-Endpunkt nimmt nur POST an (kein Server-Stream)")
|
|
}
|
|
|
|
func writeRPC(w http.ResponseWriter, id json.RawMessage, result interface{}, rpcErr *jsonrpcError) {
|
|
if len(id) == 0 {
|
|
id = json.RawMessage("null")
|
|
}
|
|
writeJSON(w, http.StatusOK, jsonrpcResponse{JSONRPC: "2.0", ID: id, Result: result, Error: rpcErr})
|
|
}
|
|
|
|
// toolResult: the data as structuredContent plus the same JSON as text for clients without
|
|
// structured output.
|
|
func toolResult(data interface{}) map[string]interface{} {
|
|
text, _ := json.MarshalIndent(data, "", " ")
|
|
return map[string]interface{}{
|
|
"content": []map[string]string{{"type": "text", "text": string(text)}},
|
|
"structuredContent": wrapStructured(data),
|
|
}
|
|
}
|
|
|
|
// structuredContent must be an object; arrays are wrapped
|
|
func wrapStructured(data interface{}) interface{} {
|
|
raw, _ := json.Marshal(data)
|
|
if strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
|
return map[string]interface{}{"items": data}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func toolError(msg string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"content": []map[string]string{{"type": "text", "text": msg}},
|
|
"isError": true,
|
|
}
|
|
}
|
|
|
|
// --- tools ---
|
|
|
|
func mcpToolByName(name string) (mcpTool, bool) {
|
|
for _, t := range mcpTools {
|
|
if t.Name == name {
|
|
return t, true
|
|
}
|
|
}
|
|
return mcpTool{}, false
|
|
}
|
|
|
|
func schema(required []string, props map[string]interface{}) map[string]interface{} {
|
|
out := map[string]interface{}{"type": "object", "properties": props}
|
|
if len(required) > 0 {
|
|
out["required"] = required
|
|
}
|
|
return out
|
|
}
|
|
|
|
func prop(typ, desc string) map[string]interface{} {
|
|
return map[string]interface{}{"type": typ, "description": desc}
|
|
}
|
|
|
|
func decodeArgs(args json.RawMessage, into interface{}) error {
|
|
if err := json.Unmarshal(args, into); err != nil {
|
|
return errors.New("Ungültige Argumente: " + err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// taskView is what an agent gets per task: the fields it can reason about, no internal bookkeeping.
|
|
type taskView struct {
|
|
ID string `json:"id"`
|
|
Content string `json:"content"`
|
|
IsCompleted bool `json:"isCompleted"`
|
|
Priority string `json:"priority"`
|
|
DueDate string `json:"dueDate,omitempty"`
|
|
TaskListID string `json:"taskListId,omitempty"`
|
|
KanbanStatus string `json:"kanbanStatus,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
|
|
Recurrence string `json:"recurrence,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Origin *domain.Origin `json:"origin,omitempty"`
|
|
}
|
|
|
|
func toTaskView(t *domain.Thought) taskView {
|
|
return taskView{
|
|
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
|
|
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
|
|
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt, Origin: t.Origin,
|
|
}
|
|
}
|
|
|
|
type taskListView struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Permission string `json:"permission"`
|
|
OpenTasks int `json:"openTasks"`
|
|
Columns []columnView `json:"columns,omitempty"`
|
|
}
|
|
|
|
// columnView: a board column, so an agent knows the valid kanbanStatus values of a task list
|
|
type columnView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
IsDone bool `json:"isDone,omitempty"`
|
|
}
|
|
|
|
func toTaskListView(l domain.TaskList) taskListView {
|
|
cols := make([]columnView, 0, len(l.Columns))
|
|
for _, c := range l.Columns {
|
|
cols = append(cols, columnView{ID: c.ID, Name: c.Name, IsDone: c.IsDone})
|
|
}
|
|
permission := l.Permission
|
|
if permission == "" && l.IsOwner {
|
|
permission = "owner"
|
|
}
|
|
return taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: permission, OpenTasks: l.TasksCount, Columns: cols}
|
|
}
|
|
|
|
type listView struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Icon string `json:"icon,omitempty"`
|
|
ListType string `json:"listType"`
|
|
Role string `json:"role"`
|
|
OpenItems int `json:"openItems"`
|
|
Items int `json:"items"`
|
|
}
|
|
|
|
func toListView(l domain.SharedList) listView {
|
|
open := 0
|
|
for _, it := range l.Items {
|
|
if !it.IsCompleted {
|
|
open++
|
|
}
|
|
}
|
|
role := l.Role
|
|
if l.IsOwner {
|
|
role = "owner"
|
|
}
|
|
return listView{ID: l.ID, Title: l.Title, Icon: l.Icon, ListType: l.ListType, Role: role, OpenItems: open, Items: len(l.Items)}
|
|
}
|
|
|
|
var mcpTools = []mcpTool{
|
|
{
|
|
Name: "list_task_lists",
|
|
Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).",
|
|
Scope: storage.ScopeTasksRead,
|
|
Schema: schema(nil, map[string]interface{}{}),
|
|
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
|
|
lists, err := s.store.GetTaskLists(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]taskListView, 0, len(lists))
|
|
for _, l := range lists {
|
|
out = append(out, toTaskListView(l))
|
|
}
|
|
return out, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "create_task_list",
|
|
Description: "Create a personal task list (board with the default columns). Returns it with its id and columns.",
|
|
Scope: storage.ScopeTasksWrite,
|
|
Schema: schema([]string{"title"}, map[string]interface{}{
|
|
"title": prop("string", "Name of the task list"),
|
|
"icon": prop("string", "One emoji, optional (default 📋)"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
Title string `json:"title"`
|
|
Icon string `json:"icon"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(a.Title) == "" {
|
|
return nil, errors.New("'title' darf nicht leer sein")
|
|
}
|
|
tl, err := s.store.CreateTaskList(userID, "", "", a.Title, a.Icon, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.broadcastTaskListChange(tl.ID, nil, "TASK_LIST_CREATED")
|
|
tl.IsOwner = true
|
|
return toTaskListView(*tl), nil
|
|
},
|
|
},
|
|
{
|
|
Name: "list_tasks",
|
|
Description: "Tasks the user can see. Defaults to open tasks across all lists; filter by taskListId, status (open|done|all) or dueBefore (YYYY-MM-DD, inclusive). Sorted: overdue and due first.",
|
|
Scope: storage.ScopeTasksRead,
|
|
Schema: schema(nil, map[string]interface{}{
|
|
"taskListId": prop("string", "Only tasks of this task list"),
|
|
"status": map[string]interface{}{"type": "string", "enum": []string{"open", "done", "all"}, "description": "Default open"},
|
|
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
|
|
"limit": prop("integer", "Maximum number of tasks (default 100)"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
TaskListID string `json:"taskListId"`
|
|
Status string `json:"status"`
|
|
DueBefore string `json:"dueBefore"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
tasks, err := s.store.GetTasks(userID, a.TaskListID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if a.Limit <= 0 {
|
|
a.Limit = 100
|
|
}
|
|
out := make([]taskView, 0)
|
|
for i := range tasks {
|
|
t := &tasks[i]
|
|
switch a.Status {
|
|
case "done":
|
|
if !t.IsCompleted {
|
|
continue
|
|
}
|
|
case "all":
|
|
default:
|
|
if t.IsCompleted {
|
|
continue
|
|
}
|
|
}
|
|
if a.DueBefore != "" && (t.DueDate == "" || t.DueDate[:min(10, len(t.DueDate))] > a.DueBefore) {
|
|
continue
|
|
}
|
|
out = append(out, toTaskView(t))
|
|
}
|
|
// Due dates first (earliest on top), then the rest in list order
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if (out[i].DueDate == "") != (out[j].DueDate == "") {
|
|
return out[i].DueDate != ""
|
|
}
|
|
return out[i].DueDate != "" && out[i].DueDate < out[j].DueDate
|
|
})
|
|
if len(out) > a.Limit {
|
|
out = out[:a.Limit]
|
|
}
|
|
return out, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_task",
|
|
Description: "One task with all details.",
|
|
Scope: storage.ScopeTasksRead,
|
|
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
t, err := s.store.GetThoughtByID(userID, a.ID)
|
|
if err != nil || t == nil {
|
|
return nil, errors.New("Aufgabe nicht gefunden")
|
|
}
|
|
return toTaskView(t), nil
|
|
},
|
|
},
|
|
{
|
|
Name: "create_task",
|
|
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
|
|
Scope: storage.ScopeTasksWrite,
|
|
AltScope: storage.ScopeTask,
|
|
Schema: schema([]string{"content"}, map[string]interface{}{
|
|
"content": prop("string", "Task text; #hashtags become tags"),
|
|
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
|
|
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
|
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
|
|
"notes": prop("string", "Longer description"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
Content string `json:"content"`
|
|
TaskListID string `json:"taskListId"`
|
|
Priority string `json:"priority"`
|
|
DueDate string `json:"dueDate"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(a.Content) == "" {
|
|
return nil, errors.New("'content' darf nicht leer sein")
|
|
}
|
|
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes, actor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toTaskView(t), nil
|
|
},
|
|
},
|
|
{
|
|
Name: "update_task",
|
|
Description: "Change fields of a task; only the given fields change. Empty dueDate removes the due date.",
|
|
Scope: storage.ScopeTasksWrite,
|
|
Schema: schema([]string{"id"}, map[string]interface{}{
|
|
"id": prop("string", "Task id"),
|
|
"content": prop("string", "New text"),
|
|
"isCompleted": prop("boolean", "true = done"),
|
|
"dueDate": prop("string", "YYYY-MM-DD, YYYY-MM-DDTHH:MM or empty"),
|
|
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
|
"notes": prop("string", "Description"),
|
|
"kanbanStatus": prop("string", "Column id of the task list's board (e.g. todo, in_progress, done)"),
|
|
"taskListId": prop("string", "Move into another task list"),
|
|
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ID string `json:"id"`
|
|
taskPatch
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
|
|
a.taskPatch.actor = actor
|
|
t, err := s.updateTask(userID, a.ID, a.taskPatch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toTaskView(t), nil
|
|
},
|
|
},
|
|
{
|
|
Name: "complete_task",
|
|
Description: "Check a task off (or reopen it with completed=false). Recurring tasks move to their next date.",
|
|
Scope: storage.ScopeTasksWrite,
|
|
Schema: schema([]string{"id"}, map[string]interface{}{
|
|
"id": prop("string", "Task id"),
|
|
"completed": prop("boolean", "Default true"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ID string `json:"id"`
|
|
Completed *bool `json:"completed"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
done := a.Completed == nil || *a.Completed
|
|
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, actor: actor})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toTaskView(t), nil
|
|
},
|
|
},
|
|
{
|
|
Name: "delete_task",
|
|
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
|
|
Scope: storage.ScopeTasksWrite,
|
|
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.store.GetThoughtByID(userID, a.ID); err != nil {
|
|
return nil, errors.New("Aufgabe nicht gefunden")
|
|
}
|
|
if err := s.deleteTask(userID, a.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]interface{}{"id": a.ID, "deleted": true}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "list_lists",
|
|
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
|
|
Scope: storage.ScopeListsRead,
|
|
Schema: schema(nil, map[string]interface{}{}),
|
|
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
|
|
lists, err := s.store.GetAllListsForUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]listView, 0, len(lists))
|
|
for _, l := range lists {
|
|
out = append(out, toListView(l))
|
|
}
|
|
return out, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "create_list",
|
|
Description: "Create a list for the user: shopping (items grouped by category), checklist or vacation (packing list).",
|
|
Scope: storage.ScopeListsWrite,
|
|
Schema: schema([]string{"title"}, map[string]interface{}{
|
|
"title": prop("string", "Name of the list"),
|
|
"icon": prop("string", "One emoji, optional"),
|
|
"listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"},
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
Title string `json:"title"`
|
|
Icon string `json:"icon"`
|
|
ListType string `json:"listType"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(a.Title) == "" {
|
|
return nil, errors.New("'title' darf nicht leer sein")
|
|
}
|
|
l, err := s.store.CreateList(userID, a.Title, a.Icon, a.ListType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.wsHub.BroadcastListEvent(l.ID, "LIST_CREATED", l)
|
|
return toListView(*l), nil
|
|
},
|
|
},
|
|
{
|
|
Name: "get_list",
|
|
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
|
|
Scope: storage.ScopeListsRead,
|
|
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ListID string `json:"listId"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
l, err := s.store.GetListByID(userID, a.ListID)
|
|
if err != nil || l == nil {
|
|
return nil, errors.New("Liste nicht gefunden")
|
|
}
|
|
return map[string]interface{}{"list": toListView(*l), "items": l.Items}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "add_list_item",
|
|
Description: "Add an item to a list the user may edit.",
|
|
Scope: storage.ScopeListsWrite,
|
|
AltScope: storage.ScopeList,
|
|
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
|
|
"listId": prop("string", "List id"),
|
|
"content": prop("string", "Item text"),
|
|
"category": prop("string", "Category/aisle, e.g. Obst"),
|
|
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ListID, Content, Category, Quantity string
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(a.Content) == "" {
|
|
return nil, errors.New("'content' darf nicht leer sein")
|
|
}
|
|
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, actor)
|
|
},
|
|
},
|
|
{
|
|
Name: "check_list_item",
|
|
Description: "Tick an item off a list (or untick it with completed=false).",
|
|
Scope: storage.ScopeListsWrite,
|
|
Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{
|
|
"listId": prop("string", "List id"),
|
|
"itemId": prop("string", "Item id from get_list"),
|
|
"completed": prop("boolean", "Default true"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ListID string `json:"listId"`
|
|
ItemID string `json:"itemId"`
|
|
Completed *bool `json:"completed"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, actor)
|
|
},
|
|
},
|
|
{
|
|
Name: "update_list_item",
|
|
Description: "Change text, category or quantity of a list item; only the given fields change.",
|
|
Scope: storage.ScopeListsWrite,
|
|
Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{
|
|
"listId": prop("string", "List id"),
|
|
"itemId": prop("string", "Item id"),
|
|
"content": prop("string", "New text"),
|
|
"category": prop("string", "New category"),
|
|
"quantity": prop("string", "New amount"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
ListID string `json:"listId"`
|
|
ItemID string `json:"itemId"`
|
|
Content *string `json:"content"`
|
|
Category *string `json:"category"`
|
|
Quantity *string `json:"quantity"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.store.EnsureListItemsWritable(userID, a.ListID, a.ItemID); err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := s.store.UpdateListItem(a.ItemID, a.Content, a.Category, a.Quantity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.recordListItemOrigin(item, actor, false)
|
|
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
|
|
return item, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "search",
|
|
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
|
|
Scope: storage.ScopeTasksRead,
|
|
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
Query string `json:"query"`
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := s.store.SearchGlobal(userID, a.Query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tasks := make([]taskView, 0, len(res.Thoughts))
|
|
for i := range res.Thoughts {
|
|
tasks = append(tasks, toTaskView(&res.Thoughts[i]))
|
|
}
|
|
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "capture_to_inbox",
|
|
Description: "Drop a thought into the user's inbox for later triage (not a task yet).",
|
|
Scope: storage.ScopeTriage,
|
|
Schema: schema([]string{"content"}, map[string]interface{}{
|
|
"content": prop("string", "The thought"),
|
|
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
|
|
"dueDate": prop("string", "YYYY-MM-DD, optional"),
|
|
}),
|
|
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
|
|
var a struct {
|
|
Content, Priority, DueDate string
|
|
}
|
|
if err := decodeArgs(args, &a); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(a.Content) == "" {
|
|
return nil, errors.New("'content' darf nicht leer sein")
|
|
}
|
|
if a.Priority == "" {
|
|
a.Priority = "normal"
|
|
}
|
|
t, err := s.store.CreateThought(userID, a.Content, a.Priority, "agent", a.DueDate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.recordThoughtOrigin(t, actor, true)
|
|
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
|
|
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
|
|
},
|
|
},
|
|
}
|
|
|
|
// filterSearchForScopes drops the parts of a search result the key may not read.
|
|
func filterSearchForScopes(result interface{}, scopes []string) interface{} {
|
|
m, ok := result.(map[string]interface{})
|
|
if !ok {
|
|
return result
|
|
}
|
|
has := map[string]bool{}
|
|
for _, sc := range scopes {
|
|
has[sc] = true
|
|
}
|
|
if !has[storage.ScopeTasksRead] {
|
|
m["tasks"] = []taskView{}
|
|
}
|
|
if !has[storage.ScopeListsRead] {
|
|
m["listItems"] = []domain.ListItem{}
|
|
}
|
|
return m
|
|
}
|