feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
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>
This commit is contained in:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+1119
-563
No files matched your search
+14
-14
@@ -10,18 +10,18 @@ import (
|
||||
|
||||
// --- API KEYS & WEBHOOKS ---
|
||||
|
||||
// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes
|
||||
// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with
|
||||
// a key.
|
||||
// Rights of an API key. They hold the same over every access: REST (curl, smart home) and the MCP
|
||||
// endpoint for AI agents. The ids of the first three come from the time they only delivered data
|
||||
// (webhooks) and stay for existing keys. Notes are never reachable with a key.
|
||||
const (
|
||||
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
||||
ScopeTask = "webhook:task" // tasks directly
|
||||
ScopeList = "webhook:list" // items into lists
|
||||
ScopeTask = "webhook:task" // create tasks
|
||||
ScopeList = "webhook:list" // add items to lists
|
||||
|
||||
ScopeTasksRead = "tasks:read" // MCP: task lists and tasks
|
||||
ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks
|
||||
ScopeListsRead = "lists:read" // MCP: lists and their items
|
||||
ScopeListsWrite = "lists:write" // MCP: add, change and check off items
|
||||
ScopeTasksRead = "tasks:read" // task lists and tasks
|
||||
ScopeTasksWrite = "tasks:write" // create, change, complete and delete tasks
|
||||
ScopeListsRead = "lists:read" // lists and their items
|
||||
ScopeListsWrite = "lists:write" // add, change and check off items
|
||||
)
|
||||
|
||||
// AllScopes in display order.
|
||||
@@ -99,19 +99,19 @@ func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||||
}
|
||||
|
||||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||||
u, _, err := s.ValidateAPIKey(rawKey)
|
||||
u, _, _, err := s.ValidateAPIKey(rawKey)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ValidateAPIKey returns the owner of a key and the key's scopes.
|
||||
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) {
|
||||
// ValidateAPIKey returns the owner of a key, the key's scopes and its id.
|
||||
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) {
|
||||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||||
var keyID, userID, scopes string
|
||||
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("invalid API key")
|
||||
return nil, nil, "", errors.New("invalid API key")
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||||
u, err := s.GetUserByID(userID)
|
||||
return u, NormalizeScopes(strings.Split(scopes, ",")), err
|
||||
return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err
|
||||
}
|
||||
@@ -483,7 +483,7 @@ func (s *Store) ReconcileDocTaskLinks(docID string) []string {
|
||||
// GetThoughtByIDUnchecked loads a task without access check, for server-internal notifications.
|
||||
func (s *Store) GetThoughtByIDUnchecked(thoughtID string) (*domain.Thought, error) {
|
||||
return scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE id = ?`, thoughtID))
|
||||
}
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ func (s *Store) DeleteList(userID, listID string) error {
|
||||
func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, `+originColumns("li.")+`
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.list_id = ?
|
||||
ORDER BY li.is_completed ASC, li.sort_order ASC, li.created_at DESC`,
|
||||
@@ -318,8 +318,10 @@ func (s *Store) GetListItems(listID string) ([]domain.ListItem, error) {
|
||||
for rows.Next() {
|
||||
var it domain.ListItem
|
||||
var doneInt int
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark); err == nil {
|
||||
var o originDest
|
||||
if err := rows.Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &doneInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt); err == nil {
|
||||
it.IsCompleted = doneInt != 0
|
||||
it.Origin = o.result()
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
@@ -399,15 +401,18 @@ func (s *Store) ToggleListItem(itemID string, isCompleted bool, userID, userName
|
||||
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
var o originDest
|
||||
err = s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at, `+originColumns("")+`
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt,
|
||||
&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
it.Origin = o.result()
|
||||
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, now, it.ListID)
|
||||
return &it, nil
|
||||
}
|
||||
@@ -416,15 +421,18 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string,
|
||||
now := domain.NowISO()
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
var o originDest
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at
|
||||
`SELECT id, list_id, content, category, quantity, is_completed, completed_by, completed_by_name, sort_order, created_at, completed_at, `+originColumns("")+`
|
||||
FROM list_items WHERE id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt,
|
||||
&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
it.Origin = o.result()
|
||||
|
||||
if content != nil {
|
||||
it.Content = *content
|
||||
@@ -459,17 +467,19 @@ func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, err
|
||||
|
||||
var it domain.ListItem
|
||||
var dInt int
|
||||
var o originDest
|
||||
err := s.db.QueryRow(
|
||||
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, li.agent_mark
|
||||
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at, `+originColumns("li.")+`
|
||||
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||
WHERE li.id = ?`,
|
||||
itemID,
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &it.AgentMark)
|
||||
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt, &o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.IsCompleted = dInt != 0
|
||||
it.Origin = o.result()
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ var migrations = []migration{
|
||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
||||
{12, "Markierung für Einträge von KI-Agenten", (*Store).migrateAgentMark},
|
||||
{13, "Herkunft von Einträgen (API-Schlüssel)", (*Store).migrateOrigin},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// Origin of tasks, thoughts and list items: which API key created an entry and which one changed it
|
||||
// last, and over which access (REST "api" or "mcp"). Both stay for good, also after the user edits the
|
||||
// entry. Key names are resolved when reading, so renaming a key shows everywhere at once.
|
||||
|
||||
const (
|
||||
OriginAPI = "api" // REST: /inbox, /webhooks/ingest and the other endpoints a key may use
|
||||
OriginMCP = "mcp"
|
||||
)
|
||||
|
||||
// Tables with origin columns; RecordOrigin only takes these
|
||||
const (
|
||||
OriginTableThoughts = "thoughts"
|
||||
OriginTableListItems = "list_items"
|
||||
)
|
||||
|
||||
// migrateOrigin replaces agent_mark (migration 12, which the user's own edit cleared) with the
|
||||
// permanent origin. Old marks and the sources webhook/agent are carried over without a key.
|
||||
func (s *Store) migrateOrigin() error {
|
||||
for _, table := range []string{OriginTableThoughts, OriginTableListItems} {
|
||||
for _, col := range []string{"created_via", "created_key_id", "changed_via", "changed_key_id", "changed_at"} {
|
||||
if err := s.addColumnIfMissing(table, col, "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.execInTx(
|
||||
stmt(`UPDATE thoughts SET created_via = 'api' WHERE created_via = '' AND source = 'webhook'`),
|
||||
stmt(`UPDATE thoughts SET created_via = 'mcp' WHERE created_via = '' AND source = 'agent'`),
|
||||
stmt(`UPDATE thoughts SET changed_via = 'mcp' WHERE changed_via = '' AND agent_mark = 'edited'`),
|
||||
stmt(`UPDATE list_items SET created_via = 'mcp' WHERE created_via = '' AND agent_mark = 'created'`),
|
||||
stmt(`UPDATE list_items SET changed_via = 'mcp' WHERE changed_via = '' AND agent_mark = 'edited'`),
|
||||
)
|
||||
}
|
||||
|
||||
// originColumns: the five origin values of a SELECT, key ids resolved to names (prefix e.g. "li." for
|
||||
// an aliased table). Scan them with scanOrigin.
|
||||
func originColumns(prefix string) string {
|
||||
keyName := func(col string) string {
|
||||
return `CASE WHEN ` + prefix + col + ` = '' THEN '' ELSE COALESCE((SELECT k.name FROM api_keys k WHERE k.id = ` +
|
||||
prefix + col + `), 'Gelöschter Schlüssel') END`
|
||||
}
|
||||
return prefix + `created_via, ` + keyName("created_key_id") + `, ` + prefix + `changed_via, ` + keyName("changed_key_id") + `, ` + prefix + `changed_at`
|
||||
}
|
||||
|
||||
// originDest holds the scanned origin columns; result is nil for entries no key ever touched.
|
||||
type originDest struct{ createdVia, createdKey, changedVia, changedKey, changedAt string }
|
||||
|
||||
func (o *originDest) targets() []any {
|
||||
return []any{&o.createdVia, &o.createdKey, &o.changedVia, &o.changedKey, &o.changedAt}
|
||||
}
|
||||
|
||||
func (o *originDest) result() *domain.Origin {
|
||||
if o.createdVia == "" && o.changedVia == "" {
|
||||
return nil
|
||||
}
|
||||
return &domain.Origin{CreatedVia: o.createdVia, CreatedKey: o.createdKey, ChangedVia: o.changedVia, ChangedKey: o.changedKey, ChangedAt: o.changedAt}
|
||||
}
|
||||
|
||||
// RecordOrigin notes that an API key created (created) or changed an entry and returns the entry's
|
||||
// origin as it is now. via is OriginAPI or OriginMCP; keyID may be empty (MCP from a login session).
|
||||
func (s *Store) RecordOrigin(table, id, via, keyID string, created bool) *domain.Origin {
|
||||
if table != OriginTableThoughts && table != OriginTableListItems {
|
||||
return nil
|
||||
}
|
||||
if created {
|
||||
_, _ = s.db.Exec(`UPDATE `+table+` SET created_via = ?, created_key_id = ? WHERE id = ?`, via, keyID, id)
|
||||
} else {
|
||||
_, _ = s.db.Exec(`UPDATE `+table+` SET changed_via = ?, changed_key_id = ?, changed_at = ? WHERE id = ?`, via, keyID, domain.NowISO(), id)
|
||||
}
|
||||
return s.Origin(table, id)
|
||||
}
|
||||
|
||||
// Origin reads the origin of one entry (nil if no key touched it).
|
||||
func (s *Store) Origin(table, id string) *domain.Origin {
|
||||
if table != OriginTableThoughts && table != OriginTableListItems {
|
||||
return nil
|
||||
}
|
||||
var o originDest
|
||||
if err := s.db.QueryRow(`SELECT `+originColumns("")+` FROM `+table+` WHERE id = ?`, id).Scan(o.targets()...); err != nil {
|
||||
return nil
|
||||
}
|
||||
return o.result()
|
||||
}
|
||||
|
||||
// copyOriginToListItem: a thought turned into a list item in the triage keeps where it came from.
|
||||
func (s *Store) copyOriginToListItem(thoughtID, itemID string) {
|
||||
cols := []string{"created_via", "created_key_id", "changed_via", "changed_key_id", "changed_at"}
|
||||
set := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
set[i] = c + ` = (SELECT ` + c + ` FROM thoughts WHERE id = ?)`
|
||||
}
|
||||
args := []any{}
|
||||
for range cols {
|
||||
args = append(args, thoughtID)
|
||||
}
|
||||
args = append(args, itemID)
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET `+strings.Join(set, ", ")+` WHERE id = ?`, args...)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Store) SetTaskRecurrence(userID, taskID, rule string) error {
|
||||
|
||||
func (s *Store) getThoughtRaw(id string) (*domain.Thought, error) {
|
||||
t, err := scanThought(s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -76,14 +76,11 @@ func (s *Store) CreateThoughtWithDetails(userID, content, priority, source, dueD
|
||||
Notes: notes,
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
if source == "agent" {
|
||||
t.AgentMark = AgentMarkCreated
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks, agent_mark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]', ?)`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt, t.AgentMark,
|
||||
`INSERT INTO thoughts (id, user_id, content, tags, priority, source, status, is_completed, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, subtasks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, ?, '[]')`,
|
||||
t.ID, t.UserID, t.Content, string(tagsJSON), t.Priority, t.Source, string(t.Status), t.DueDate, t.TaskListID, t.KanbanStatus, t.Notes, t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,16 +95,19 @@ func scanThought(rows interface {
|
||||
t := &domain.Thought{}
|
||||
var tagsStr, subtasksStr, statusStr string
|
||||
var isCompleted, alarmSent, sortOrder int
|
||||
var origin originDest
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Content, &tagsStr, &t.Priority, &t.Source,
|
||||
&statusStr, &isCompleted, &t.ConvertedType, &t.ConvertedRefID,
|
||||
&t.DueDate, &t.TaskListID, &t.KanbanStatus, &t.Notes, &alarmSent,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence, &t.AgentMark,
|
||||
&t.CreatedAt, &t.TriagedAt, &subtasksStr, &sortOrder, &t.Recurrence,
|
||||
&origin.createdVia, &origin.createdKey, &origin.changedVia, &origin.changedKey, &origin.changedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.SortOrder = sortOrder
|
||||
t.Origin = origin.result()
|
||||
t.Status = domain.ThoughtStatus(statusStr)
|
||||
t.IsCompleted = isCompleted != 0
|
||||
t.AlarmSent = alarmSent != 0
|
||||
@@ -139,7 +139,7 @@ func (s *Store) accessibleTaskListIDsQuery() string {
|
||||
|
||||
func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE id = ? AND (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))`,
|
||||
thoughtID, userID, userID, userID, userID,
|
||||
)
|
||||
@@ -160,14 +160,14 @@ func (s *Store) GetThoughts(userID, statusFilter string, limit int) ([]domain.Th
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE user_id = ? AND status = ?
|
||||
ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, statusFilter, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts WHERE user_id = ?
|
||||
ORDER BY sort_order ASC, created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
@@ -193,7 +193,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
var err error
|
||||
if taskListID != "" {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND task_list_id = ?
|
||||
@@ -203,7 +203,7 @@ func (s *Store) GetTasks(userID string, taskListID string) ([]domain.Thought, er
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, agent_mark
|
||||
`SELECT id, user_id, content, tags, priority, source, status, is_completed, converted_type, converted_ref_id, due_date, task_list_id, kanban_status, notes, alarm_sent, created_at, triaged_at, subtasks, sort_order, recurrence, `+originColumns("")+`
|
||||
FROM thoughts
|
||||
WHERE (user_id = ? OR task_list_id IN (`+s.accessibleTaskListIDsQuery()+`))
|
||||
AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))
|
||||
@@ -384,6 +384,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
|
||||
if err == nil && item != nil {
|
||||
convertedRefID = item.ID
|
||||
s.copyOriginToListItem(thought.ID, item.ID)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -512,7 +513,8 @@ func (s *Store) DeleteThought(userID, thoughtID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateAgentMark: entries an AI agent created or changed via MCP and the user has not touched since
|
||||
// migrateAgentMark: released in v0.29.0; agent_mark is superseded by the origin columns (migrateOrigin)
|
||||
// and only read once to carry old marks over
|
||||
func (s *Store) migrateAgentMark() error {
|
||||
if err := s.addColumnIfMissing("thoughts", "agent_mark", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
@@ -520,51 +522,6 @@ func (s *Store) migrateAgentMark() error {
|
||||
return s.addColumnIfMissing("list_items", "agent_mark", "TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
|
||||
// Values of agent_mark on thoughts and list items
|
||||
const (
|
||||
AgentMarkCreated = "created"
|
||||
AgentMarkEdited = "edited"
|
||||
)
|
||||
|
||||
// nextAgentMark: an agent's change marks the entry (an entry it created stays "created"), a change by
|
||||
// the user removes the mark.
|
||||
func nextAgentMark(current string, byAgent bool) string {
|
||||
if !byAgent {
|
||||
return ""
|
||||
}
|
||||
if current == AgentMarkCreated {
|
||||
return AgentMarkCreated
|
||||
}
|
||||
return AgentMarkEdited
|
||||
}
|
||||
|
||||
// SetThoughtAgentMark records who changed a task last and returns the new mark.
|
||||
func (s *Store) SetThoughtAgentMark(thoughtID, current string, byAgent bool) string {
|
||||
mark := nextAgentMark(current, byAgent)
|
||||
if mark != current {
|
||||
_, _ = s.db.Exec(`UPDATE thoughts SET agent_mark = ? WHERE id = ?`, mark, thoughtID)
|
||||
}
|
||||
return mark
|
||||
}
|
||||
|
||||
// SetListItemAgentMark is SetThoughtAgentMark for list items; the current mark is read here because
|
||||
// the item queries after a change do not carry it.
|
||||
func (s *Store) SetListItemAgentMark(itemID string, byAgent bool) string {
|
||||
var current string
|
||||
_ = s.db.QueryRow(`SELECT agent_mark FROM list_items WHERE id = ?`, itemID).Scan(¤t)
|
||||
mark := nextAgentMark(current, byAgent)
|
||||
if mark != current {
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, mark, itemID)
|
||||
}
|
||||
return mark
|
||||
}
|
||||
|
||||
// MarkListItemAgentCreated marks an item an agent just added.
|
||||
func (s *Store) MarkListItemAgentCreated(itemID string) string {
|
||||
_, _ = s.db.Exec(`UPDATE list_items SET agent_mark = ? WHERE id = ?`, AgentMarkCreated, itemID)
|
||||
return AgentMarkCreated
|
||||
}
|
||||
|
||||
func (s *Store) PurgeDiscardedThoughts(userID string) (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID)
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user