Fundamentet under Kvasir, og det er værd at have uden ham. Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere af samme postkasse — en anden assistent, en gentaget kørsel, et menneske — laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal faktura fra Browns" to gange i samme kørsel. Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`, `imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og det her skal virke uden. Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal være ude af stand til" er en betingelse frem for en aftale, nogen husker. Afgrænset til den, mailen kom til — to personer på samme instans, der begge modtager tråden, får hver sin opgave, og det er rigtigt. CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da af standser samme sted. `TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave, der blev lavet af en mail og smidt væk, er også et svar.
1025 lines
32 KiB
Go
1025 lines
32 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/recurrence"
|
|
)
|
|
|
|
type Task struct {
|
|
ID string
|
|
ProjectID string
|
|
SectionID string
|
|
ParentID string
|
|
Content string
|
|
Description string
|
|
Priority int
|
|
// DueDate is a calendar day, "YYYY-MM-DD". DueDatetime is set as well when the
|
|
// task has a clock time, so day-based queries never do timezone arithmetic.
|
|
DueDate string
|
|
DueDatetime *time.Time
|
|
DueTimezone string
|
|
DurationMin *int
|
|
RecurrenceRule string
|
|
AssigneeID string
|
|
CompletedAt *time.Time
|
|
CompletedBy string
|
|
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
|
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
|
CreatedBy string
|
|
// SourceKey names the thing out there this task was made from, namespaced:
|
|
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
|
|
//
|
|
// It exists so that the second reader of a mailbox — another assistant, a
|
|
// rerun, a person — can ask "has this already become a task?" and get an
|
|
// answer. CreateTask returns ErrDuplicate rather than making a second one.
|
|
SourceKey string
|
|
SortOrder float64
|
|
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
|
// that there is something underneath it, and how much of it is left. Counted
|
|
// for every task in a list, in the same query — a hundred extra lookups to draw
|
|
// a badge is not a badge worth having.
|
|
SubtaskCount int
|
|
SubtaskDone int
|
|
// AttachmentCount is the same idea for files. A task with a drawing on it looks
|
|
// exactly like one without, and the whole reason to attach something is that
|
|
// somebody should find it.
|
|
AttachmentCount int
|
|
Labels []string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
|
|
|
// TaskFilter is the one query shape behind every list of tasks: a project view,
|
|
// Today, Upcoming, a label, an assignee. Zero values mean "do not filter on this".
|
|
type TaskFilter struct {
|
|
ProjectIDs []string
|
|
SectionID string
|
|
ParentID string
|
|
// TopLevelOnly excludes sub-tasks, which a project view wants because it
|
|
// renders them nested under their parents rather than as separate rows.
|
|
TopLevelOnly bool
|
|
AssigneeID string
|
|
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
|
|
// person. A second field rather than a magic value in AssigneeID, because the
|
|
// two are different questions — "what is on my plate" and "what have I handed
|
|
// over" — and one string that means an id most of the time and a sentinel the
|
|
// rest is the kind of field somebody eventually passes a real id to.
|
|
DelegatedBy string
|
|
LabelID string
|
|
Priority int
|
|
|
|
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
|
|
DueBefore string
|
|
DueFrom string
|
|
// NoDate selects tasks with no due date at all.
|
|
NoDate bool
|
|
|
|
IncludeCompleted bool
|
|
// CompletedOnly powers the "completed" view and the activity log.
|
|
CompletedOnly bool
|
|
|
|
Search string
|
|
// FilterSQL is a compiled saved-filter expression: a WHERE fragment over `t`
|
|
// with its own bound arguments. It is produced by the filterquery package and
|
|
// never assembled from user text here.
|
|
FilterSQL string
|
|
FilterArgs []any
|
|
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// ListTasks returns tasks the user can see, matching the filter.
|
|
//
|
|
// Visibility is enforced in the query rather than by filtering afterwards: the
|
|
// WHERE clause restricts to projects the user owns or is a member of, so a task in
|
|
// somebody else's project cannot be reached by guessing a filter, and a paginated
|
|
// response cannot come back short because rows were removed after LIMIT.
|
|
func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Task, error) {
|
|
var where []string
|
|
var args []any
|
|
|
|
where = append(where, `t.deleted_at IS NULL`)
|
|
where = append(where, `p.deleted_at IS NULL`)
|
|
where = append(where, `(p.owner_id = ? OR EXISTS (
|
|
SELECT 1 FROM project_members m WHERE m.project_id = t.project_id AND m.user_id = ?))`)
|
|
args = append(args, userID, userID)
|
|
|
|
switch {
|
|
case f.CompletedOnly:
|
|
where = append(where, `t.completed_at IS NOT NULL`)
|
|
case !f.IncludeCompleted:
|
|
where = append(where, `t.completed_at IS NULL`)
|
|
}
|
|
|
|
if len(f.ProjectIDs) > 0 {
|
|
where = append(where, `t.project_id IN (`+placeholders(len(f.ProjectIDs))+`)`)
|
|
for _, id := range f.ProjectIDs {
|
|
args = append(args, id)
|
|
}
|
|
}
|
|
if f.SectionID != "" {
|
|
where = append(where, `t.section_id = ?`)
|
|
args = append(args, f.SectionID)
|
|
}
|
|
if f.ParentID != "" {
|
|
where = append(where, `t.parent_id = ?`)
|
|
args = append(args, f.ParentID)
|
|
}
|
|
if f.TopLevelOnly {
|
|
where = append(where, `t.parent_id IS NULL`)
|
|
}
|
|
if f.AssigneeID != "" {
|
|
where = append(where, `t.assignee_id = ?`)
|
|
args = append(args, f.AssigneeID)
|
|
}
|
|
if f.DelegatedBy != "" {
|
|
where = append(where, `t.assignee_id IS NOT NULL AND t.assignee_id <> ?`)
|
|
args = append(args, f.DelegatedBy)
|
|
}
|
|
if f.Priority > 0 {
|
|
where = append(where, `t.priority = ?`)
|
|
args = append(args, f.Priority)
|
|
}
|
|
if f.LabelID != "" {
|
|
where = append(where, `EXISTS (SELECT 1 FROM task_labels tl WHERE tl.task_id = t.id AND tl.label_id = ?)`)
|
|
args = append(args, f.LabelID)
|
|
}
|
|
if f.NoDate {
|
|
where = append(where, `t.due_date IS NULL`)
|
|
}
|
|
if f.DueBefore != "" {
|
|
where = append(where, `t.due_date IS NOT NULL AND t.due_date <= ?`)
|
|
args = append(args, f.DueBefore)
|
|
}
|
|
if f.DueFrom != "" {
|
|
where = append(where, `t.due_date IS NOT NULL AND t.due_date >= ?`)
|
|
args = append(args, f.DueFrom)
|
|
}
|
|
if expr := MatchExpr(f.Search); expr != "" {
|
|
// Text or a label. A label is how you find a task whose wording you have
|
|
// forgotten — "that thing about the accounts" is a search for @regnskab —
|
|
// and leaving them out made the box look like it had lost the task.
|
|
//
|
|
// The label side is a plain lowercase match rather than another FTS index:
|
|
// labels are short, and their names are typed by the person searching for
|
|
// them, so there is no spelling to be generous about. The diacritic
|
|
// folding that matters for task text does not earn a second index here.
|
|
//
|
|
// The third arm is a word inside a word. An index is built of tokens, and
|
|
// `SpeedAdmin` is one of them — so "speed admin" asks for two words that are
|
|
// nowhere. A prefix query gets half of it (`speed*` matches, `admin*` cannot)
|
|
// and the two are joined with AND, so the half that works is thrown away.
|
|
//
|
|
// Notes got the same arm, and for the same reason. Having it in one of the
|
|
// two would be worse than having it in neither: "it finds it in my notes but
|
|
// not in my tasks" is a bug report waiting, and the answer to it would be
|
|
// this line.
|
|
//
|
|
// It scans, and it only ever runs when somebody typed something in the box —
|
|
// Today and Kommende do not set `Search`. Every term has to appear, so the
|
|
// arms are ANDed with each other while the whole thing is ORed with the two
|
|
// above.
|
|
inside := make([]string, 0, maxSearchTerms)
|
|
insideArgs := make([]any, 0, maxSearchTerms)
|
|
for _, term := range SearchTerms(f.Search) {
|
|
inside = append(inside, "t.fold LIKE ?")
|
|
insideArgs = append(insideArgs, "%"+FoldDanish(term)+"%")
|
|
}
|
|
text := ""
|
|
if len(inside) > 0 {
|
|
text = " OR (" + strings.Join(inside, " AND ") + ")"
|
|
}
|
|
|
|
where = append(where, `(
|
|
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
|
OR t.id IN (
|
|
SELECT tl.task_id FROM task_labels tl
|
|
JOIN labels l ON l.id = tl.label_id
|
|
WHERE lower(l.name) LIKE ?
|
|
)`+text+`
|
|
)`)
|
|
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
|
args = append(args, insideArgs...)
|
|
}
|
|
if f.FilterSQL != "" {
|
|
where = append(where, "("+f.FilterSQL+")")
|
|
args = append(args, f.FilterArgs...)
|
|
}
|
|
|
|
query := `
|
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
|
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
|
FROM tasks t
|
|
JOIN projects p ON p.id = t.project_id
|
|
WHERE ` + strings.Join(where, " AND ") + `
|
|
ORDER BY ` + order(f)
|
|
|
|
if f.Limit > 0 {
|
|
query += ` LIMIT ?`
|
|
args = append(args, f.Limit)
|
|
if f.Offset > 0 {
|
|
query += ` OFFSET ?`
|
|
args = append(args, f.Offset)
|
|
}
|
|
}
|
|
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tasks := []Task{}
|
|
ids := []string{}
|
|
for rows.Next() {
|
|
t, err := scanTask(rows)
|
|
if err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
tasks = append(tasks, t)
|
|
ids = append(ids, t.ID)
|
|
}
|
|
err = rows.Err()
|
|
// Closed explicitly, before the next query rather than by a deferred call at
|
|
// the end of the function. An open *sql.Rows holds its connection, and this
|
|
// pool is small on purpose — running the label lookup below while these rows
|
|
// were still open would wait for a connection that this very function is
|
|
// holding, which is a deadlock rather than a slow query.
|
|
rows.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Labels in one further query rather than a join: joining would multiply every
|
|
// task row by its label count and require de-duplication in Go, which is more
|
|
// work and more code than a second round trip against a local file.
|
|
if err := db.attachLabels(ctx, tasks, ids); err != nil {
|
|
return nil, err
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
func (db *DB) attachLabels(ctx context.Context, tasks []Task, ids []string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
args[i] = id
|
|
}
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT tl.task_id, l.name FROM task_labels tl
|
|
JOIN labels l ON l.id = tl.label_id
|
|
WHERE tl.task_id IN (`+placeholders(len(ids))+`)
|
|
ORDER BY l.name`, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
byTask := map[string][]string{}
|
|
for rows.Next() {
|
|
var taskID, name string
|
|
if err := rows.Scan(&taskID, &name); err != nil {
|
|
return err
|
|
}
|
|
byTask[taskID] = append(byTask[taskID], name)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
for i := range tasks {
|
|
tasks[i].Labels = byTask[tasks[i].ID]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error) {
|
|
if _, err := TaskRole(ctx, db, taskID, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
|
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
|
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var t Task
|
|
found := rows.Next()
|
|
if found {
|
|
t, err = scanTask(rows)
|
|
}
|
|
if err == nil {
|
|
err = rows.Err()
|
|
}
|
|
// Closed before the label lookup below, for the reason given in ListTasks:
|
|
// an open result set holds its connection.
|
|
rows.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
if t.Labels, err = db.taskLabels(ctx, t.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT l.name FROM task_labels tl JOIN labels l ON l.id = tl.label_id
|
|
WHERE tl.task_id = ? ORDER BY l.name`, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []string
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// taskCounts are what a row can say about itself without being opened.
|
|
//
|
|
// Correlated subqueries rather than a second round of lookups: a list of fifty
|
|
// tasks would otherwise be a hundred more queries, and the whole point is that a
|
|
// row shows this without anybody asking for it. `idx_tasks_parent` covers the
|
|
// first two; the attachment count is a small table with a task_id on it.
|
|
//
|
|
// Sub-tasks are counted whether they are open or closed, and the finished ones
|
|
// again separately, because "3" and "1 of 3" are different sentences and only the
|
|
// second one says whether there is anything left to do.
|
|
const taskCounts = `
|
|
(SELECT count(*) FROM tasks c
|
|
WHERE c.parent_id = t.id AND c.deleted_at IS NULL),
|
|
(SELECT count(*) FROM tasks c
|
|
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
|
|
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
|
|
|
|
// order is how a list of tasks is sorted, which depends on what the list is for.
|
|
//
|
|
// A plan is read by when it is due and how urgent it is. A record of what has been
|
|
// finished is read backwards from now — "what did I just do" and "what did I close
|
|
// by mistake" are both questions about the most recent thing, and neither is
|
|
// answered by a due date the task no longer has.
|
|
func order(f TaskFilter) string {
|
|
if f.CompletedOnly {
|
|
return "t.completed_at DESC, t.id DESC"
|
|
}
|
|
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
|
}
|
|
|
|
func scanTask(rows *sql.Rows) (Task, error) {
|
|
var t Task
|
|
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
|
// createdBy is null on a task whose author's account has been deleted. The work
|
|
// stays; it loses its author.
|
|
var createdBy sql.NullString
|
|
var dueDatetime, completedAt sql.NullInt64
|
|
var duration sql.NullInt64
|
|
var created, updated int64
|
|
|
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
|
&recurrence, &assignee, &completedAt, &completedBy,
|
|
&createdBy, &t.SortOrder, &created, &updated,
|
|
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
|
if err != nil {
|
|
return t, err
|
|
}
|
|
|
|
t.SectionID = sectionID.String
|
|
t.ParentID = parentID.String
|
|
t.CreatedBy = createdBy.String
|
|
t.DueDate = dueDate.String
|
|
t.DueTimezone = dueTZ.String
|
|
t.RecurrenceRule = recurrence.String
|
|
t.AssigneeID = assignee.String
|
|
t.CompletedBy = completedBy.String
|
|
if dueDatetime.Valid {
|
|
v := time.Unix(dueDatetime.Int64, 0).UTC()
|
|
t.DueDatetime = &v
|
|
}
|
|
if completedAt.Valid {
|
|
v := time.Unix(completedAt.Int64, 0).UTC()
|
|
t.CompletedAt = &v
|
|
}
|
|
if duration.Valid {
|
|
v := int(duration.Int64)
|
|
t.DurationMin = &v
|
|
}
|
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
|
return t, nil
|
|
}
|
|
|
|
func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) error {
|
|
if t.ID == "" {
|
|
t.ID = NewID()
|
|
}
|
|
if t.Priority < 1 || t.Priority > 4 {
|
|
t.Priority = 4
|
|
}
|
|
now := time.Now().UTC()
|
|
t.CreatedAt, t.UpdatedAt = now, now
|
|
|
|
if t.SortOrder == 0 {
|
|
order, err := db.nextTaskOrder(ctx, t.ProjectID, t.SectionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.SortOrder = order
|
|
}
|
|
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
|
priority, due_date, due_datetime, due_timezone, duration_min,
|
|
recurrence_rule, assignee_id, created_by, source_key, sort_order,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
|
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
|
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
|
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
|
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
|
|
if err != nil {
|
|
// The one broken constraint that is not a bug: this source has already
|
|
// become a task. The caller skips it rather than making a duplicate,
|
|
// which is the entire reason the index exists.
|
|
if isUniqueViolation(err) {
|
|
return ErrDuplicate
|
|
}
|
|
return err
|
|
}
|
|
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
|
})
|
|
}
|
|
|
|
// TaskIDBySource answers "has this mail already become a task?" — the question
|
|
// nothing could answer before, and the one that stops two assistants reading the
|
|
// same inbox from making two of everything. Empty id and no error means no.
|
|
//
|
|
// Deleted tasks count. A task that was made from a mail and then thrown away is
|
|
// an answer — remaking it on the next sweep would be the same duplicate wearing
|
|
// a different hat.
|
|
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
|
|
if sourceKey == "" {
|
|
return "", nil
|
|
}
|
|
var id string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
|
|
userID, sourceKey).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", nil
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
|
var max sql.NullFloat64
|
|
var err error
|
|
if sectionID == "" {
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT max(sort_order) FROM tasks
|
|
WHERE project_id = ? AND section_id IS NULL AND deleted_at IS NULL`,
|
|
projectID).Scan(&max)
|
|
} else {
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT max(sort_order) FROM tasks
|
|
WHERE project_id = ? AND section_id = ? AND deleted_at IS NULL`,
|
|
projectID, sectionID).Scan(&max)
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return max.Float64 + 1024, nil
|
|
}
|
|
|
|
// TaskUpdate mirrors ProjectUpdate: nil means the field was not sent.
|
|
//
|
|
// The due fields are grouped, because clearing a date must also clear the time and
|
|
// leaving them independent would allow a task due at 10:00 on no particular day.
|
|
type TaskUpdate struct {
|
|
Content *string
|
|
Description *string
|
|
Priority *int
|
|
SectionID *string
|
|
ParentID *string
|
|
AssigneeID *string
|
|
RecurrenceRule *string
|
|
DurationMin *int
|
|
SortOrder *float64
|
|
|
|
SetDue bool
|
|
DueDate string
|
|
DueDatetime *time.Time
|
|
DueTimezone string
|
|
|
|
Labels []string
|
|
SetLabels bool
|
|
}
|
|
|
|
func (db *DB) UpdateTask(ctx context.Context, taskID, userID string, u TaskUpdate) error {
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
set, args := buildUpdate(map[string]any{
|
|
"content": u.Content,
|
|
"description": u.Description,
|
|
"priority": u.Priority,
|
|
"sort_order": u.SortOrder,
|
|
})
|
|
|
|
// These four are nullable, so an empty string means "clear it" rather than
|
|
// "set it to the empty string" — a task's section, parent and assignee are
|
|
// all things a user removes as often as they set.
|
|
for _, f := range []struct {
|
|
column string
|
|
value *string
|
|
}{
|
|
{"section_id", u.SectionID},
|
|
{"parent_id", u.ParentID},
|
|
{"assignee_id", u.AssigneeID},
|
|
{"recurrence_rule", u.RecurrenceRule},
|
|
} {
|
|
if f.value != nil {
|
|
set = append(set, f.column+" = ?")
|
|
args = append(args, nullString(*f.value))
|
|
}
|
|
}
|
|
if u.DurationMin != nil {
|
|
set = append(set, "duration_min = ?")
|
|
if *u.DurationMin <= 0 {
|
|
args = append(args, nil)
|
|
} else {
|
|
args = append(args, *u.DurationMin)
|
|
}
|
|
}
|
|
if u.SetDue {
|
|
set = append(set, "due_date = ?", "due_datetime = ?", "due_timezone = ?")
|
|
args = append(args, nullString(u.DueDate), nullTime(u.DueDatetime), nullString(u.DueTimezone))
|
|
}
|
|
|
|
if len(set) > 0 {
|
|
set = append(set, "updated_at = ?")
|
|
args = append(args, time.Now().Unix(), taskID)
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE tasks SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
}
|
|
|
|
if u.SetLabels {
|
|
return setTaskLabels(ctx, tx, taskID, userID, u.Labels)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// setTaskLabels replaces a task's labels, creating any that the user does not have
|
|
// yet. Labels are personal, so the same word used by two people is two labels —
|
|
// which is what stops one person's tidying from renaming another's.
|
|
func setTaskLabels(ctx context.Context, tx *sql.Tx, taskID, userID string, names []string) error {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM task_labels WHERE task_id = ?`, taskID); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().Unix()
|
|
for _, name := range names {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
var labelID string
|
|
err := tx.QueryRowContext(ctx,
|
|
`SELECT id FROM labels WHERE user_id = ? AND lower(name) = lower(?)`,
|
|
userID, name).Scan(&labelID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
labelID = NewID()
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO labels (id, user_id, name, color, sort_order, created_at)
|
|
VALUES (?, ?, ?, 'graphite', 0, ?)`, labelID, userID, name, now); err != nil {
|
|
return err
|
|
}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)
|
|
ON CONFLICT DO NOTHING`, taskID, labelID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CompleteResult says what completing a task actually did.
|
|
//
|
|
// A recurring task is not finished by being ticked off — it moves to its next
|
|
// occurrence. The caller needs to know which happened so it can tell the person,
|
|
// and so the activity log records a completion rather than an edit.
|
|
type CompleteResult struct {
|
|
// Recurred is true when the task advanced instead of closing.
|
|
Recurred bool
|
|
// NextDue is the new due date, set only when Recurred.
|
|
NextDue string
|
|
}
|
|
|
|
// CompleteTask marks a task done, along with everything under it.
|
|
//
|
|
// Sub-tasks are completed with their parent because the parent standing complete
|
|
// over unfinished children is a state the UI would then have to explain. Reopening
|
|
// does not undo that: which children were deliberately still open is not recorded,
|
|
// and guessing would be worse than leaving them done.
|
|
//
|
|
// A task with a recurrence rule takes a different path entirely — see recurTask.
|
|
func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) (CompleteResult, error) {
|
|
// The recurring case is decided before the transaction that closes the task,
|
|
// because the two are mutually exclusive: a repeating chore that has been done
|
|
// this week is not finished, it is due again next week.
|
|
rule, dueDate, err := db.recurrenceOf(ctx, taskID)
|
|
if err != nil {
|
|
return CompleteResult{}, err
|
|
}
|
|
if rule != "" {
|
|
return db.recurTask(ctx, taskID, userID, rule, dueDate)
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`,
|
|
now, userID, now, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
// Recursive, so a whole sub-tree closes rather than only its first level.
|
|
_, err = tx.ExecContext(ctx, `
|
|
WITH RECURSIVE descendants(id) AS (
|
|
SELECT id FROM tasks WHERE parent_id = ?
|
|
UNION ALL
|
|
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
|
|
)
|
|
UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ?
|
|
WHERE id IN (SELECT id FROM descendants)
|
|
AND deleted_at IS NULL AND completed_at IS NULL`,
|
|
taskID, now, userID, now)
|
|
return err
|
|
})
|
|
return CompleteResult{}, err
|
|
}
|
|
|
|
func (db *DB) recurrenceOf(ctx context.Context, taskID string) (rule, dueDate string, err error) {
|
|
var r, d sql.NullString
|
|
err = db.QueryRowContext(ctx,
|
|
`SELECT recurrence_rule, due_date FROM tasks
|
|
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`, taskID).Scan(&r, &d)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", "", ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return r.String, d.String, nil
|
|
}
|
|
|
|
// recurTask advances a repeating task to its next occurrence.
|
|
//
|
|
// The row is kept and its date moved, rather than closing this one and inserting a
|
|
// successor. That keeps the task's id stable, so its sub-tasks, comments and
|
|
// attachments stay attached — which is what somebody means by "my weekly review",
|
|
// a thing that persists, rather than fifty-two separate tasks.
|
|
//
|
|
// A completion is still written to the activity log, because "what did I get done
|
|
// this week" has to include the chores that repeat.
|
|
func (db *DB) recurTask(ctx context.Context, taskID, userID, rule, dueDate string) (CompleteResult, error) {
|
|
// The anchor is the task's own due date. A repeating task with no date has
|
|
// nothing to advance from, so today stands in — which is also what somebody
|
|
// adding "hver mandag" with no start date means.
|
|
anchor := time.Now().UTC()
|
|
if dueDate != "" {
|
|
if parsed, err := time.Parse("2006-01-02", dueDate); err == nil {
|
|
anchor = parsed
|
|
}
|
|
}
|
|
|
|
next, err := recurrence.Next(rule, anchor)
|
|
if err != nil {
|
|
// A finite series that has run out really is finished. Close it, so the
|
|
// task does not sit there for ever refusing to be completed.
|
|
if errors.Is(err, recurrence.ErrSeriesEnded) {
|
|
if _, cerr := db.clearRecurrence(ctx, taskID); cerr != nil {
|
|
return CompleteResult{}, cerr
|
|
}
|
|
return db.CompleteTask(ctx, taskID, userID)
|
|
}
|
|
return CompleteResult{}, fmt.Errorf("advance recurrence %q: %w", rule, err)
|
|
}
|
|
|
|
nextDue := next.Format("2006-01-02")
|
|
now := time.Now().Unix()
|
|
|
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
|
// due_datetime is recomputed from the new day, keeping the clock time: a
|
|
// task due every Monday at 10:00 stays due at 10:00.
|
|
res, err := tx.ExecContext(ctx, `
|
|
UPDATE tasks
|
|
SET due_date = ?,
|
|
due_datetime = CASE
|
|
WHEN due_datetime IS NULL THEN NULL
|
|
ELSE due_datetime + (julianday(?) - julianday(due_date)) * 86400
|
|
END,
|
|
updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`,
|
|
nextDue, nextDue, now, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return CompleteResult{}, err
|
|
}
|
|
return CompleteResult{Recurred: true, NextDue: nextDue}, nil
|
|
}
|
|
|
|
func (db *DB) clearRecurrence(ctx context.Context, taskID string) (int64, error) {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE tasks SET recurrence_rule = NULL, updated_at = ? WHERE id = ?`,
|
|
time.Now().Unix(), taskID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
func (db *DB) ReopenTask(ctx context.Context, taskID string) error {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE tasks SET completed_at = NULL, completed_by = NULL, updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL`, time.Now().Unix(), taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTask is a soft delete, and takes the sub-tree with it.
|
|
func (db *DB) DeleteTask(ctx context.Context, taskID string) error {
|
|
now := time.Now().Unix()
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
|
now, now, taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
WITH RECURSIVE descendants(id) AS (
|
|
SELECT id FROM tasks WHERE parent_id = ?
|
|
UNION ALL
|
|
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
|
|
)
|
|
UPDATE tasks SET deleted_at = ?, updated_at = ?
|
|
WHERE id IN (SELECT id FROM descendants) AND deleted_at IS NULL`,
|
|
taskID, now, now)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (db *DB) RestoreTask(ctx context.Context, taskID string) error {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE tasks SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
|
|
time.Now().Unix(), taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MoveTask places a task between two neighbours and returns its new position.
|
|
//
|
|
// Ordering is a float, so an insert is the midpoint of its neighbours and touches
|
|
// one row — no renumbering, which on a drag-and-drop list would mean rewriting
|
|
// every task in the project on every drop.
|
|
//
|
|
// Floats do run out of room: repeatedly dropping into the same gap halves it each
|
|
// time, and after about fifty drops the midpoint of two neighbours is equal to one
|
|
// of them. Then the section is spread back out and the move retried. The renumber
|
|
// runs in its own transaction, because the attempt that discovered the problem
|
|
// rolls back — doing it inside that transaction would undo the repair along with it.
|
|
func (db *DB) MoveTask(ctx context.Context, taskID, projectID, sectionID string, afterID, beforeID string) (float64, error) {
|
|
order, err := db.tryMove(ctx, taskID, projectID, sectionID, afterID, beforeID)
|
|
if !errors.Is(err, errOrderExhausted) {
|
|
return order, err
|
|
}
|
|
|
|
if err := db.Tx(ctx, func(tx *sql.Tx) error {
|
|
return renumber(ctx, tx, projectID, sectionID)
|
|
}); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// One retry: after a renumber the gaps are wide again, so a second failure
|
|
// would mean something other than exhausted precision.
|
|
order, err = db.tryMove(ctx, taskID, projectID, sectionID, afterID, beforeID)
|
|
if errors.Is(err, errOrderExhausted) {
|
|
return 0, errors.New("store: could not find a position for the task")
|
|
}
|
|
return order, err
|
|
}
|
|
|
|
var errOrderExhausted = errors.New("store: no float left between these two positions")
|
|
|
|
func (db *DB) tryMove(ctx context.Context, taskID, projectID, sectionID, afterID, beforeID string) (float64, error) {
|
|
var order float64
|
|
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
|
after, hasAfter, err := neighbourOrder(ctx, tx, afterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before, hasBefore, err := neighbourOrder(ctx, tx, beforeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch {
|
|
case hasAfter && hasBefore:
|
|
order = after + (before-after)/2
|
|
if order <= after || order >= before {
|
|
return errOrderExhausted
|
|
}
|
|
case hasAfter:
|
|
order = after + 1024
|
|
case hasBefore:
|
|
order = before - 1024
|
|
default:
|
|
order = 1024
|
|
}
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE tasks SET project_id = ?, section_id = ?, sort_order = ?, updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL`,
|
|
projectID, nullString(sectionID), order, time.Now().Unix(), taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
return order, err
|
|
}
|
|
|
|
func neighbourOrder(ctx context.Context, tx *sql.Tx, id string) (float64, bool, error) {
|
|
if id == "" {
|
|
return 0, false, nil
|
|
}
|
|
var order float64
|
|
err := tx.QueryRowContext(ctx,
|
|
`SELECT sort_order FROM tasks WHERE id = ? AND deleted_at IS NULL`, id).Scan(&order)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
return order, true, nil
|
|
}
|
|
|
|
// renumber spreads a section's tasks back out to whole multiples of 1024.
|
|
func renumber(ctx context.Context, tx *sql.Tx, projectID, sectionID string) error {
|
|
var rows *sql.Rows
|
|
var err error
|
|
if sectionID == "" {
|
|
rows, err = tx.QueryContext(ctx,
|
|
`SELECT id FROM tasks WHERE project_id = ? AND section_id IS NULL AND deleted_at IS NULL
|
|
ORDER BY sort_order, created_at`, projectID)
|
|
} else {
|
|
rows, err = tx.QueryContext(ctx,
|
|
`SELECT id FROM tasks WHERE project_id = ? AND section_id = ? AND deleted_at IS NULL
|
|
ORDER BY sort_order, created_at`, projectID, sectionID)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
for i, id := range ids {
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE tasks SET sort_order = ? WHERE id = ?`, float64((i+1)*1024), id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PurgeTrash deletes for real what has been in the trash longer than the retention
|
|
// window. This is the only place a row leaves the database.
|
|
func (db *DB) PurgeTrash(ctx context.Context, olderThan time.Duration) (int64, error) {
|
|
cutoff := time.Now().Add(-olderThan).Unix()
|
|
var total int64
|
|
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
|
res, err := tx.ExecContext(ctx,
|
|
`DELETE FROM tasks WHERE deleted_at IS NOT NULL AND deleted_at < ?`, cutoff)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
total += n
|
|
|
|
res, err = tx.ExecContext(ctx,
|
|
`DELETE FROM projects WHERE deleted_at IS NOT NULL AND deleted_at < ?`, cutoff)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ = res.RowsAffected()
|
|
total += n
|
|
return nil
|
|
})
|
|
return total, err
|
|
}
|
|
|
|
func nullString(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func nullTime(t *time.Time) any {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.Unix()
|
|
}
|
|
|
|
func nullInt(n *int) any {
|
|
if n == nil {
|
|
return nil
|
|
}
|
|
return *n
|
|
}
|
|
|
|
func placeholders(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
|
}
|