Skip to main content

mps/api/
mod.rs

1//! Shared JSON request/response types and element conversion for the HTTP API.
2
3use crate::elements::{Element, ElementKind};
4use crate::ref_resolver::RefResolver;
5use serde::{Deserialize, Serialize};
6
7// ── Response types ────────────────────────────────────────────────────────────
8
9/// Flat JSON representation of any element type.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ElementJson {
12    /// Epoch ref e.g. "20260501.1"
13    #[serde(rename = "ref")]
14    pub epoch_ref: String,
15    /// Human-readable ref e.g. "task-1" (None if resolver has no mapping)
16    pub human_ref: Option<String>,
17    /// Date string "YYYY-MM-DD"
18    pub date: String,
19    /// Element type: task, note, log, reminder, character
20    #[serde(rename = "type")]
21    pub element_type: String,
22    pub tags: Vec<String>,
23    pub body: String,
24    // Type-specific optional fields
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub status: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub at: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub start: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub end: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub name: Option<String>,
35}
36
37/// Stats response.
38#[derive(Debug, Clone, Serialize)]
39pub struct StatsJson {
40    pub dates: Vec<String>,
41    pub tasks: TaskStats,
42    pub notes: usize,
43    pub logs: LogStats,
44    pub reminders: usize,
45    pub characters: usize,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct TaskStats {
50    pub total: usize,
51    pub open: usize,
52    pub done: usize,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct LogStats {
57    pub total: usize,
58    pub duration_minutes: i64,
59}
60
61/// Generic JSON error body.
62#[derive(Debug, Clone, Serialize)]
63pub struct ErrorResponse {
64    pub error: String,
65}
66
67// ── Request types ─────────────────────────────────────────────────────────────
68
69/// POST /elements body.
70#[derive(Debug, Clone, Deserialize)]
71pub struct AppendRequest {
72    #[serde(rename = "type")]
73    pub element_type: String,
74    pub body: String,
75    #[serde(default)]
76    pub tags: Vec<String>,
77    pub status: Option<String>,
78    pub at: Option<String>,
79    pub start: Option<String>,
80    pub end: Option<String>,
81    pub name: Option<String>,
82    /// "YYYY-MM-DD" — defaults to today when absent.
83    pub date: Option<String>,
84}
85
86/// PATCH /elements/:ref body.
87#[derive(Debug, Clone, Deserialize)]
88pub struct UpdateRequest {
89    pub status: Option<String>,
90    pub at: Option<String>,
91    pub start: Option<String>,
92    pub end: Option<String>,
93    pub name: Option<String>,
94    /// Replace the element's body text.
95    pub body: Option<String>,
96    /// "YYYY-MM-DD" date context for human refs — defaults to today.
97    pub date: Option<String>,
98}
99
100// ── Conversion helpers ────────────────────────────────────────────────────────
101
102/// Convert a parsed Element into its API JSON representation.
103pub fn element_to_json(
104    el: &Element,
105    epoch_ref: &str,
106    date_str: &str,
107    resolver: &RefResolver,
108) -> ElementJson {
109    let human_ref = resolver.to_human(epoch_ref).map(|s| s.to_string());
110    let date = format_date_str(date_str);
111
112    match el {
113        Element::Task { data, body_str, .. } => ElementJson {
114            epoch_ref: epoch_ref.to_string(),
115            human_ref,
116            date,
117            element_type: "task".into(),
118            tags: data.tags.clone(),
119            body: body_str.trim().to_string(),
120            status: Some(data.status_str().to_string()),
121            at: None,
122            start: None,
123            end: None,
124            name: None,
125        },
126        Element::Note { data, body_str, .. } => ElementJson {
127            epoch_ref: epoch_ref.to_string(),
128            human_ref,
129            date,
130            element_type: "note".into(),
131            tags: data.tags.clone(),
132            body: body_str.trim().to_string(),
133            status: None,
134            at: None,
135            start: None,
136            end: None,
137            name: None,
138        },
139        Element::Log { data, body_str, .. } => ElementJson {
140            epoch_ref: epoch_ref.to_string(),
141            human_ref,
142            date,
143            element_type: "log".into(),
144            tags: data.tags.clone(),
145            body: body_str.trim().to_string(),
146            status: None,
147            at: None,
148            start: data.start.clone(),
149            end: data.end.clone(),
150            name: None,
151        },
152        Element::Reminder { data, body_str, .. } => ElementJson {
153            epoch_ref: epoch_ref.to_string(),
154            human_ref,
155            date,
156            element_type: "reminder".into(),
157            tags: data.tags.clone(),
158            body: body_str.trim().to_string(),
159            status: None,
160            at: data.at.clone(),
161            start: None,
162            end: None,
163            name: None,
164        },
165        Element::Character { data, body_str, .. } => ElementJson {
166            epoch_ref: epoch_ref.to_string(),
167            human_ref,
168            date,
169            element_type: "character".into(),
170            tags: data.tags.clone(),
171            body: body_str.trim().to_string(),
172            status: None,
173            at: None,
174            start: None,
175            end: None,
176            name: data.name.clone(),
177        },
178        Element::MpsGroup { body_str, .. } => ElementJson {
179            epoch_ref: epoch_ref.to_string(),
180            human_ref,
181            date,
182            element_type: "mps".into(),
183            tags: vec![],
184            body: body_str.trim().to_string(),
185            status: None,
186            at: None,
187            start: None,
188            end: None,
189            name: None,
190        },
191        Element::Unknown { sign, body_str, .. } => ElementJson {
192            epoch_ref: epoch_ref.to_string(),
193            human_ref,
194            date,
195            element_type: sign.clone(),
196            tags: vec![],
197            body: body_str.trim().to_string(),
198            status: None,
199            at: None,
200            start: None,
201            end: None,
202            name: None,
203        },
204    }
205}
206
207/// Convert a raw "YYYYMMDD" date prefix to "YYYY-MM-DD".
208pub fn format_date_str(date_str: &str) -> String {
209    if date_str.len() >= 8 {
210        format!("{}-{}-{}", &date_str[..4], &date_str[4..6], &date_str[6..8])
211    } else {
212        date_str.to_string()
213    }
214}
215
216/// Compute stats over a set of elements grouped by date.
217pub fn compute_stats(
218    date_elements: Vec<(String, indexmap::IndexMap<String, Element>)>,
219) -> StatsJson {
220    let mut dates = Vec::new();
221    let mut task_total = 0usize;
222    let mut task_open = 0usize;
223    let mut task_done = 0usize;
224    let mut notes = 0usize;
225    let mut log_total = 0usize;
226    let mut log_duration = 0i64;
227    let mut reminders = 0usize;
228    let mut characters = 0usize;
229
230    for (date_str, els) in date_elements {
231        dates.push(format_date_str(&date_str));
232        for (_, el) in &els {
233            match el.kind() {
234                ElementKind::Task => {
235                    task_total += 1;
236                    if let Element::Task { data, .. } = el {
237                        if data.is_done() {
238                            task_done += 1;
239                        } else {
240                            task_open += 1;
241                        }
242                    }
243                }
244                ElementKind::Note => notes += 1,
245                ElementKind::Log => {
246                    log_total += 1;
247                    if let Element::Log { data, .. } = el {
248                        if let Some(mins) = data.duration_minutes() {
249                            log_duration += mins;
250                        }
251                    }
252                }
253                ElementKind::Reminder => reminders += 1,
254                ElementKind::Character => characters += 1,
255                ElementKind::MpsGroup | ElementKind::Unknown => {}
256            }
257        }
258    }
259
260    dates.sort();
261    dates.dedup();
262
263    StatsJson {
264        dates,
265        tasks: TaskStats {
266            total: task_total,
267            open: task_open,
268            done: task_done,
269        },
270        notes,
271        logs: LogStats {
272            total: log_total,
273            duration_minutes: log_duration,
274        },
275        reminders,
276        characters,
277    }
278}