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!(
211            "{}-{}-{}",
212            &date_str[..4],
213            &date_str[4..6],
214            &date_str[6..8]
215        )
216    } else {
217        date_str.to_string()
218    }
219}
220
221/// Compute stats over a set of elements grouped by date.
222pub fn compute_stats(
223    date_elements: Vec<(String, indexmap::IndexMap<String, Element>)>,
224) -> StatsJson {
225    let mut dates = Vec::new();
226    let mut task_total = 0usize;
227    let mut task_open = 0usize;
228    let mut task_done = 0usize;
229    let mut notes = 0usize;
230    let mut log_total = 0usize;
231    let mut log_duration = 0i64;
232    let mut reminders = 0usize;
233    let mut characters = 0usize;
234
235    for (date_str, els) in date_elements {
236        dates.push(format_date_str(&date_str));
237        for (_, el) in &els {
238            match el.kind() {
239                ElementKind::Task => {
240                    task_total += 1;
241                    if let Element::Task { data, .. } = el {
242                        if data.is_done() {
243                            task_done += 1;
244                        } else {
245                            task_open += 1;
246                        }
247                    }
248                }
249                ElementKind::Note => notes += 1,
250                ElementKind::Log => {
251                    log_total += 1;
252                    if let Element::Log { data, .. } = el {
253                        if let Some(mins) = data.duration_minutes() {
254                            log_duration += mins as i64;
255                        }
256                    }
257                }
258                ElementKind::Reminder => reminders += 1,
259                ElementKind::Character => characters += 1,
260                ElementKind::MpsGroup | ElementKind::Unknown => {}
261            }
262        }
263    }
264
265    dates.sort();
266    dates.dedup();
267
268    StatsJson {
269        dates,
270        tasks: TaskStats {
271            total: task_total,
272            open: task_open,
273            done: task_done,
274        },
275        notes,
276        logs: LogStats {
277            total: log_total,
278            duration_minutes: log_duration,
279        },
280        reminders,
281        characters,
282    }
283}