mps-rs 1.7.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
//! Shared JSON request/response types and element conversion for the HTTP API.

use crate::elements::{Element, ElementKind};
use crate::ref_resolver::RefResolver;
use serde::{Deserialize, Serialize};

// ── Response types ────────────────────────────────────────────────────────────

/// Flat JSON representation of any element type.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementJson {
    /// Epoch ref e.g. "20260501.1"
    #[serde(rename = "ref")]
    pub epoch_ref: String,
    /// Human-readable ref e.g. "task-1" (None if resolver has no mapping)
    pub human_ref: Option<String>,
    /// Date string "YYYY-MM-DD"
    pub date: String,
    /// Element type: task, note, log, reminder, character
    #[serde(rename = "type")]
    pub element_type: String,
    pub tags: Vec<String>,
    pub body: String,
    // Type-specific optional fields
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

/// Stats response.
#[derive(Debug, Clone, Serialize)]
pub struct StatsJson {
    pub dates: Vec<String>,
    pub tasks: TaskStats,
    pub notes: usize,
    pub logs: LogStats,
    pub reminders: usize,
    pub characters: usize,
}

#[derive(Debug, Clone, Serialize)]
pub struct TaskStats {
    pub total: usize,
    pub open: usize,
    pub done: usize,
}

#[derive(Debug, Clone, Serialize)]
pub struct LogStats {
    pub total: usize,
    pub duration_minutes: i64,
}

/// Generic JSON error body.
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResponse {
    pub error: String,
}

// ── Request types ─────────────────────────────────────────────────────────────

/// POST /elements body.
#[derive(Debug, Clone, Deserialize)]
pub struct AppendRequest {
    #[serde(rename = "type")]
    pub element_type: String,
    pub body: String,
    #[serde(default)]
    pub tags: Vec<String>,
    pub status: Option<String>,
    pub at: Option<String>,
    pub start: Option<String>,
    pub end: Option<String>,
    pub name: Option<String>,
    /// "YYYY-MM-DD" — defaults to today when absent.
    pub date: Option<String>,
}

/// PATCH /elements/:ref body.
#[derive(Debug, Clone, Deserialize)]
pub struct UpdateRequest {
    pub status: Option<String>,
    pub at: Option<String>,
    pub start: Option<String>,
    pub end: Option<String>,
    pub name: Option<String>,
    /// "YYYY-MM-DD" date context for human refs — defaults to today.
    pub date: Option<String>,
}

// ── Conversion helpers ────────────────────────────────────────────────────────

/// Convert a parsed Element into its API JSON representation.
pub fn element_to_json(
    el: &Element,
    epoch_ref: &str,
    date_str: &str,
    resolver: &RefResolver,
) -> ElementJson {
    let human_ref = resolver.to_human(epoch_ref).map(|s| s.to_string());
    let date = format_date_str(date_str);

    match el {
        Element::Task { data, body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: "task".into(),
            tags: data.tags.clone(),
            body: body_str.trim().to_string(),
            status: Some(data.status_str().to_string()),
            at: None,
            start: None,
            end: None,
            name: None,
        },
        Element::Note { data, body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: "note".into(),
            tags: data.tags.clone(),
            body: body_str.trim().to_string(),
            status: None,
            at: None,
            start: None,
            end: None,
            name: None,
        },
        Element::Log { data, body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: "log".into(),
            tags: data.tags.clone(),
            body: body_str.trim().to_string(),
            status: None,
            at: None,
            start: data.start.clone(),
            end: data.end.clone(),
            name: None,
        },
        Element::Reminder { data, body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: "reminder".into(),
            tags: data.tags.clone(),
            body: body_str.trim().to_string(),
            status: None,
            at: data.at.clone(),
            start: None,
            end: None,
            name: None,
        },
        Element::Character { data, body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: "character".into(),
            tags: data.tags.clone(),
            body: body_str.trim().to_string(),
            status: None,
            at: None,
            start: None,
            end: None,
            name: data.name.clone(),
        },
        Element::MpsGroup { body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: "mps".into(),
            tags: vec![],
            body: body_str.trim().to_string(),
            status: None,
            at: None,
            start: None,
            end: None,
            name: None,
        },
        Element::Unknown { sign, body_str, .. } => ElementJson {
            epoch_ref: epoch_ref.to_string(),
            human_ref,
            date,
            element_type: sign.clone(),
            tags: vec![],
            body: body_str.trim().to_string(),
            status: None,
            at: None,
            start: None,
            end: None,
            name: None,
        },
    }
}

/// Convert a raw "YYYYMMDD" date prefix to "YYYY-MM-DD".
pub fn format_date_str(date_str: &str) -> String {
    if date_str.len() >= 8 {
        format!(
            "{}-{}-{}",
            &date_str[..4],
            &date_str[4..6],
            &date_str[6..8]
        )
    } else {
        date_str.to_string()
    }
}

/// Compute stats over a set of elements grouped by date.
pub fn compute_stats(
    date_elements: Vec<(String, indexmap::IndexMap<String, Element>)>,
) -> StatsJson {
    let mut dates = Vec::new();
    let mut task_total = 0usize;
    let mut task_open = 0usize;
    let mut task_done = 0usize;
    let mut notes = 0usize;
    let mut log_total = 0usize;
    let mut log_duration = 0i64;
    let mut reminders = 0usize;
    let mut characters = 0usize;

    for (date_str, els) in date_elements {
        dates.push(format_date_str(&date_str));
        for (_, el) in &els {
            match el.kind() {
                ElementKind::Task => {
                    task_total += 1;
                    if let Element::Task { data, .. } = el {
                        if data.is_done() {
                            task_done += 1;
                        } else {
                            task_open += 1;
                        }
                    }
                }
                ElementKind::Note => notes += 1,
                ElementKind::Log => {
                    log_total += 1;
                    if let Element::Log { data, .. } = el {
                        if let Some(mins) = data.duration_minutes() {
                            log_duration += mins as i64;
                        }
                    }
                }
                ElementKind::Reminder => reminders += 1,
                ElementKind::Character => characters += 1,
                ElementKind::MpsGroup | ElementKind::Unknown => {}
            }
        }
    }

    dates.sort();
    dates.dedup();

    StatsJson {
        dates,
        tasks: TaskStats {
            total: task_total,
            open: task_open,
            done: task_done,
        },
        notes,
        logs: LogStats {
            total: log_total,
            duration_minutes: log_duration,
        },
        reminders,
        characters,
    }
}