MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s
MindSift now speaks MCP (Streamable HTTP, stateless JSON-RPC) at POST /api/v1/mcp so Claude Code, Codex, Claude Desktop, Cursor and custom agents can read, create and complete tasks and keep shared lists. API keys get the scopes tasks:read/write and lists:read/write; tools/list only shows what the key may call and every tool checks its scope. Task and list write paths are shared helpers (updateTask, createTask, addListItem, setListItemDone) used by REST and MCP alike. Settings: scope selection and the SectionPicker (import, restore) use ToggleSwitch rows instead of checkbox-like buttons; new MCP section with connection snippets for Claude Code, Codex and JSON configs. Docs, README, help and changelog updated; v0.28.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/sandrodach/mindsift/internal/auth"
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
"github.com/sandrodach/mindsift/internal/storage"
|
|
)
|
|
|
|
func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
var req struct {
|
|
Content string `json:"content"`
|
|
Priority string `json:"priority"`
|
|
Source string `json:"source"`
|
|
DueDate string `json:"dueDate"`
|
|
DueDate2 string `json:"due_date"`
|
|
Target string `json:"target"` // inbox | task | list_item | document
|
|
TargetListID string `json:"targetListId"`
|
|
TargetList2 string `json:"target_list_id"`
|
|
TaskListID string `json:"taskListId"`
|
|
TaskListID2 string `json:"task_list_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
dueDate := req.DueDate
|
|
if dueDate == "" {
|
|
dueDate = req.DueDate2
|
|
}
|
|
targetListID := req.TargetListID
|
|
if targetListID == "" {
|
|
targetListID = req.TargetList2
|
|
}
|
|
taskListID := req.TaskListID
|
|
if taskListID == "" {
|
|
taskListID = req.TaskListID2
|
|
}
|
|
|
|
source := strings.ToLower(strings.TrimSpace(req.Source))
|
|
if source == "" {
|
|
source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
|
|
}
|
|
if source == "" {
|
|
source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source")))
|
|
}
|
|
if source == "" {
|
|
// When called via API key or webhook token, automatically default source to "webhook"
|
|
if r.Header.Get("X-API-Key") != "" ||
|
|
strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") ||
|
|
strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") {
|
|
source = "webhook"
|
|
} else {
|
|
source = "manual"
|
|
}
|
|
}
|
|
|
|
if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) {
|
|
return
|
|
}
|
|
|
|
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
|
if err != nil {
|
|
writeStoreError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
target := strings.TrimSpace(req.Target)
|
|
if target == "task" || target == "document" || (target == "list_item" && targetListID != "") {
|
|
if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate, taskListID); convErr == nil && converted != nil {
|
|
thought = converted
|
|
if target == "list_item" && targetListID != "" {
|
|
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
|
|
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
|
|
writeJSON(w, http.StatusCreated, thought)
|
|
}
|
|
|
|
func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
status := r.URL.Query().Get("status")
|
|
thoughts, err := s.store.GetThoughts(userID, status, 250)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
s.markWatching(userID, thoughts)
|
|
writeJSON(w, http.StatusOK, thoughts)
|
|
}
|
|
|
|
func (s *Server) handleGetThought(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
thought, err := s.store.GetThoughtByID(userID, thoughtID)
|
|
if err != nil || thought == nil {
|
|
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, thought)
|
|
}
|
|
|
|
func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughts, err := s.store.GetThoughts(userID, "inbox", 100)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, thoughts)
|
|
}
|
|
|
|
func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
|
|
var req struct {
|
|
Action string `json:"action"` // keep | discard | restore
|
|
ConvertTo string `json:"convertTo"`
|
|
ConvertTo2 string `json:"convert_to"`
|
|
TargetListID string `json:"targetListId"`
|
|
TargetList2 string `json:"target_list_id"`
|
|
TaskListID string `json:"taskListId"`
|
|
TaskListID2 string `json:"task_list_id"`
|
|
DueDate string `json:"dueDate"`
|
|
DueDate2 string `json:"due_date"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
|
|
convertTo := req.ConvertTo
|
|
if convertTo == "" {
|
|
convertTo = req.ConvertTo2
|
|
}
|
|
targetListID := req.TargetListID
|
|
if targetListID == "" {
|
|
targetListID = req.TargetList2
|
|
}
|
|
taskListID := req.TaskListID
|
|
if taskListID == "" {
|
|
taskListID = req.TaskListID2
|
|
}
|
|
dueDate := req.DueDate
|
|
if dueDate == "" {
|
|
dueDate = req.DueDate2
|
|
}
|
|
|
|
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
|
|
if err != nil {
|
|
writeStoreError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
|
|
// If converted to a list item, broadcast via WebSocket to the space
|
|
if convertTo == "list_item" && targetListID != "" {
|
|
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
|
|
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
|
|
}
|
|
}
|
|
|
|
s.wsHub.BroadcastToUsers(s.taskAudience(updated), "TASK_UPDATED", updated)
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
taskListID := r.URL.Query().Get("taskListId")
|
|
tasks, err := s.store.GetTasks(userID, taskListID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tasks)
|
|
}
|
|
|
|
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
taskListID := chi.URLParam(r, "id")
|
|
if taskListID == "" {
|
|
taskListID = r.URL.Query().Get("taskListId")
|
|
}
|
|
|
|
var req struct {
|
|
Items []domain.ThoughtReorderItem `json:"items"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
|
|
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
|
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
|
return
|
|
}
|
|
// ReorderTasks updates by ID only, so every item must be a task the caller may edit
|
|
audience := []string{userID}
|
|
for _, it := range req.Items {
|
|
th, err := s.store.GetThoughtByID(userID, it.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
|
|
return
|
|
}
|
|
if th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
|
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
|
return
|
|
}
|
|
audience = append(audience, s.taskAudience(th)...)
|
|
}
|
|
|
|
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
s.wsHub.BroadcastToUsers(audience, "TASK_REORDERED", map[string]interface{}{
|
|
"taskListId": taskListID,
|
|
"items": req.Items,
|
|
"userId": userID,
|
|
})
|
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
// taskPatch is a partial change of a task; nil fields stay as they are. Shared by PATCH /thoughts/{id}
|
|
// and the MCP tools.
|
|
type taskPatch struct {
|
|
Content *string `json:"content"`
|
|
IsCompleted *bool `json:"isCompleted"`
|
|
DueDate *string `json:"dueDate"`
|
|
Priority *string `json:"priority"`
|
|
TaskListID *string `json:"taskListId"`
|
|
KanbanStatus *string `json:"kanbanStatus"`
|
|
Notes *string `json:"notes"`
|
|
Subtasks *[]domain.SubTask `json:"subtasks"`
|
|
Tags *[]string `json:"tags"`
|
|
Assignees *[]string `json:"assignees"`
|
|
Recurrence *string `json:"recurrence"`
|
|
}
|
|
|
|
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
|
// and sends the notifications. Errors come from the store (writeStoreError maps them).
|
|
func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Thought, error) {
|
|
before, _ := s.store.GetThoughtByID(userID, thoughtID)
|
|
// The rule is set first, so completing a task in the same request already repeats it
|
|
if req.Recurrence != nil {
|
|
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Assignees != nil {
|
|
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updated.Assignees = assignees
|
|
}
|
|
if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" {
|
|
_, head, _ := s.store.GetDocOpsSince(docID, 0, 0)
|
|
s.notifyDocChanged(docID, head)
|
|
}
|
|
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
|
s.notifyTaskChange(userID, before, updated, false)
|
|
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
|
|
var req taskPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
updated, err := s.updateTask(userID, thoughtID, req)
|
|
if err != nil {
|
|
writeStoreError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
if err := s.store.UnlinkTaskFromDocBlock(userID, thoughtID); err != nil {
|
|
writeStoreError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
|
|
}
|
|
|
|
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
thoughtID := chi.URLParam(r, "id")
|
|
th, err := s.store.GetThoughtByID(userID, thoughtID)
|
|
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
|
|
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
|
return
|
|
}
|
|
audience := s.taskAudience(th)
|
|
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
|
|
writeStoreError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
if th != nil {
|
|
s.notifyTaskChange(userID, th, nil, true)
|
|
}
|
|
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
|
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
count, err := s.store.PurgeDiscardedThoughts(userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int64{"purged": count})
|
|
}
|
|
|
|
// --- SEARCH & EXPORT ---
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
q := r.URL.Query().Get("q")
|
|
res, err := s.store.SearchGlobal(userID, q)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|