sara-tasks 2.7.6

Sara — folder-aware task manager
use super::*;
use anyhow::Result;
use chrono::{DateTime, Utc};
use rusqlite::Connection;
use std::collections::HashMap;
use uuid::Uuid;

pub const DOING_MAX_CHARS: usize = 200;
pub const DOING_KEEP_PER_TASK: i64 = 200;

pub fn parse_at(s: &str) -> DateTime<Utc> {
    str_to_dt(s)
        .or_else(|_| {
            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
                .map(|n| n.and_utc())
                .map_err(anyhow::Error::from)
        })
        .unwrap_or(DateTime::<Utc>::MIN_UTC)
}

fn row_to_activity(r: &rusqlite::Row<'_>) -> rusqlite::Result<ActivityEntry> {
    let task: String = r.get(1)?;
    let at: String = r.get(4)?;
    Ok(ActivityEntry {
        id: r.get(0)?,
        task_uuid: Uuid::parse_str(&task).unwrap_or_default(),
        text: r.get(2)?,
        client: r.get(3)?,
        at: parse_at(&at),
    })
}

pub fn record_doing(
    conn: &Connection,
    task_uuid: &Uuid,
    text: &str,
    client: Option<&str>,
) -> Result<ActivityEntry> {
    let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
    if text.is_empty() {
        anyhow::bail!("`doing` text cannot be empty");
    }
    let text: String = text.chars().take(DOING_MAX_CHARS).collect();
    let client = client.map(str::trim).filter(|c| !c.is_empty());
    let at = Utc::now();
    conn.execute(
        "INSERT INTO task_activity (task_uuid, text, client, at) VALUES (?1, ?2, ?3, ?4)",
        rusqlite::params![task_uuid.to_string(), text, client, dt_to_str(&at)],
    )?;
    let id = conn.last_insert_rowid();
    conn.execute(
        "DELETE FROM task_activity WHERE task_uuid = ?1 AND id NOT IN \
         (SELECT id FROM task_activity WHERE task_uuid = ?1 ORDER BY id DESC LIMIT ?2)",
        rusqlite::params![task_uuid.to_string(), DOING_KEEP_PER_TASK],
    )?;
    Ok(ActivityEntry {
        id,
        task_uuid: *task_uuid,
        text,
        client: client.map(str::to_owned),
        at,
    })
}

pub fn doing_for_task(conn: &Connection, task_uuid: &Uuid) -> Result<Vec<ActivityEntry>> {
    let mut stmt = conn.prepare(
        "SELECT id, task_uuid, text, client, at FROM task_activity
         WHERE task_uuid = ?1 ORDER BY id",
    )?;
    let rows = stmt
        .query_map([task_uuid.to_string()], row_to_activity)?
        .collect::<rusqlite::Result<Vec<_>>>()?;
    Ok(rows)
}

pub fn latest_doing(conn: &Connection, task_uuid: &Uuid) -> Result<Option<ActivityEntry>> {
    let mut stmt = conn.prepare(
        "SELECT id, task_uuid, text, client, at FROM task_activity
         WHERE task_uuid = ?1 ORDER BY id DESC LIMIT 1",
    )?;
    let mut rows = stmt.query_map([task_uuid.to_string()], row_to_activity)?;
    Ok(rows.next().transpose()?)
}

pub fn data_version(conn: &Connection) -> Result<i64> {
    Ok(conn.query_row("PRAGMA data_version", [], |r| r.get(0))?)
}

const EVENT_TIMES: &str = "
    SELECT task_uuid, at FROM task_activity
    UNION ALL SELECT task_uuid, done_at FROM task_checklist WHERE done_at IS NOT NULL
    UNION ALL SELECT task_uuid, entry FROM annotations
    UNION ALL SELECT task_uuid, changed_at FROM task_history
    UNION ALL SELECT task_uuid, at FROM memory_uses
    UNION ALL SELECT task_uuid, created_at FROM task_ai_runs";

pub fn last_event_times(conn: &Connection) -> Result<HashMap<Uuid, DateTime<Utc>>> {
    let mut stmt = conn.prepare(&format!("SELECT task_uuid, at FROM ({EVENT_TIMES})"))?;
    let mut out: HashMap<Uuid, DateTime<Utc>> = HashMap::new();
    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
    for (task, at) in rows.flatten() {
        let Ok(uuid) = Uuid::parse_str(&task) else {
            continue;
        };
        let at = parse_at(&at);
        out.entry(uuid)
            .and_modify(|cur| {
                if at > *cur {
                    *cur = at;
                }
            })
            .or_insert(at);
    }
    Ok(out)
}

pub fn open_tasks_by_activity(
    conn: &Connection,
    project: Option<&str>,
) -> Result<Vec<(Task, DateTime<Utc>)>> {
    let last = last_event_times(conn)?;
    let mut out: Vec<(Task, DateTime<Utc>)> = list_tasks(conn, project)?
        .into_iter()
        .map(|t| {
            let at = last.get(&t.uuid).copied().unwrap_or(t.modified);
            (t, at)
        })
        .collect();
    out.sort_by_key(|e| std::cmp::Reverse(e.1));
    Ok(out)
}

fn change_text(h: &HistoryEntry) -> String {
    match (&h.old_value, &h.new_value) {
        (Some(o), Some(n)) => format!("{}: {o} → {n}", h.field),
        (None, Some(n)) => format!("{}: {n}", h.field),
        (Some(o), None) => format!("{}: removed {o}", h.field),
        (None, None) => h.field.clone(),
    }
}

pub fn flow_events(conn: &Connection, task_uuid: &Uuid) -> Result<Vec<FlowEvent>> {
    let mut events = Vec::new();
    for a in doing_for_task(conn, task_uuid)? {
        events.push(FlowEvent {
            at: a.at,
            kind: FlowKind::Doing,
            text: a.text,
            detail: a.client,
        });
    }
    for s in get_checklist(conn, task_uuid)? {
        if let Some(done_at) = s.done_at.as_deref().filter(|_| s.done) {
            events.push(FlowEvent {
                at: parse_at(done_at),
                kind: FlowKind::StepDone,
                text: s.text,
                detail: s.result,
            });
        }
    }
    for n in get_annotations(conn, task_uuid)? {
        events.push(FlowEvent {
            at: n.entry,
            kind: FlowKind::Note(n.kind),
            text: n.text,
            detail: Some(n.author),
        });
    }
    for h in get_history(conn, task_uuid)? {
        let dup = h.field == "annotation"
            || (h.field == "checklist" && h.new_value.as_deref() == Some("step done"));
        if dup {
            continue;
        }
        let kind = if matches!(h.field.as_str(), "checklist" | "acceptance")
            && h.old_value.is_none()
            && h.new_value
                .as_deref()
                .is_some_and(|v| v != "step reopened" && v != "step reordered")
        {
            FlowKind::StepAdded
        } else {
            FlowKind::Change(h.field.clone())
        };
        let text = if kind == FlowKind::StepAdded {
            h.new_value.clone().unwrap_or_default()
        } else {
            change_text(&h)
        };
        events.push(FlowEvent {
            at: h.changed_at,
            kind,
            text,
            detail: None,
        });
    }
    for u in memory_uses_for_task(conn, task_uuid)? {
        let label = get_item_by_uuid(conn, &u.item_uuid.to_string())
            .ok()
            .and_then(|i| i.display_id)
            .map(|n| format!("m{n}"))
            .unwrap_or_else(|| u.item_uuid.to_string()[..8].to_string());
        events.push(FlowEvent {
            at: u.at,
            kind: FlowKind::Memory(u.kind.as_str().to_string()),
            text: label,
            detail: None,
        });
    }
    for r in get_ai_runs(conn, task_uuid)? {
        events.push(FlowEvent {
            at: r.created_at,
            kind: FlowKind::AiRun,
            text: r.kind,
            detail: r.model,
        });
    }
    events.sort_by_key(|e| e.at);
    Ok(events)
}