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    /// "YYYY-MM-DD" date context for human refs — defaults to today.
95    pub date: Option<String>,
96}
97
98// ── Conversion helpers ────────────────────────────────────────────────────────
99
100/// Convert a parsed Element into its API JSON representation.
101pub fn element_to_json(
102    el: &Element,
103    epoch_ref: &str,
104    date_str: &str,
105    resolver: &RefResolver,
106) -> ElementJson {
107    let human_ref = resolver.to_human(epoch_ref).map(|s| s.to_string());
108    let date = format_date_str(date_str);
109
110    match el {
111        Element::Task { data, body_str, .. } => ElementJson {
112            epoch_ref: epoch_ref.to_string(),
113            human_ref,
114            date,
115            element_type: "task".into(),
116            tags: data.tags.clone(),
117            body: body_str.trim().to_string(),
118            status: Some(data.status_str().to_string()),
119            at: None,
120            start: None,
121            end: None,
122            name: None,
123        },
124        Element::Note { data, body_str, .. } => ElementJson {
125            epoch_ref: epoch_ref.to_string(),
126            human_ref,
127            date,
128            element_type: "note".into(),
129            tags: data.tags.clone(),
130            body: body_str.trim().to_string(),
131            status: None,
132            at: None,
133            start: None,
134            end: None,
135            name: None,
136        },
137        Element::Log { data, body_str, .. } => ElementJson {
138            epoch_ref: epoch_ref.to_string(),
139            human_ref,
140            date,
141            element_type: "log".into(),
142            tags: data.tags.clone(),
143            body: body_str.trim().to_string(),
144            status: None,
145            at: None,
146            start: data.start.clone(),
147            end: data.end.clone(),
148            name: None,
149        },
150        Element::Reminder { data, body_str, .. } => ElementJson {
151            epoch_ref: epoch_ref.to_string(),
152            human_ref,
153            date,
154            element_type: "reminder".into(),
155            tags: data.tags.clone(),
156            body: body_str.trim().to_string(),
157            status: None,
158            at: data.at.clone(),
159            start: None,
160            end: None,
161            name: None,
162        },
163        Element::Character { data, body_str, .. } => ElementJson {
164            epoch_ref: epoch_ref.to_string(),
165            human_ref,
166            date,
167            element_type: "character".into(),
168            tags: data.tags.clone(),
169            body: body_str.trim().to_string(),
170            status: None,
171            at: None,
172            start: None,
173            end: None,
174            name: data.name.clone(),
175        },
176        Element::MpsGroup { body_str, .. } => ElementJson {
177            epoch_ref: epoch_ref.to_string(),
178            human_ref,
179            date,
180            element_type: "mps".into(),
181            tags: vec![],
182            body: body_str.trim().to_string(),
183            status: None,
184            at: None,
185            start: None,
186            end: None,
187            name: None,
188        },
189        Element::Unknown { sign, body_str, .. } => ElementJson {
190            epoch_ref: epoch_ref.to_string(),
191            human_ref,
192            date,
193            element_type: sign.clone(),
194            tags: vec![],
195            body: body_str.trim().to_string(),
196            status: None,
197            at: None,
198            start: None,
199            end: None,
200            name: None,
201        },
202    }
203}
204
205/// Convert a raw "YYYYMMDD" date prefix to "YYYY-MM-DD".
206pub fn format_date_str(date_str: &str) -> String {
207    if date_str.len() >= 8 {
208        format!(
209            "{}-{}-{}",
210            &date_str[..4],
211            &date_str[4..6],
212            &date_str[6..8]
213        )
214    } else {
215        date_str.to_string()
216    }
217}
218
219/// Compute stats over a set of elements grouped by date.
220pub fn compute_stats(
221    date_elements: Vec<(String, indexmap::IndexMap<String, Element>)>,
222) -> StatsJson {
223    let mut dates = Vec::new();
224    let mut task_total = 0usize;
225    let mut task_open = 0usize;
226    let mut task_done = 0usize;
227    let mut notes = 0usize;
228    let mut log_total = 0usize;
229    let mut log_duration = 0i64;
230    let mut reminders = 0usize;
231    let mut characters = 0usize;
232
233    for (date_str, els) in date_elements {
234        dates.push(format_date_str(&date_str));
235        for (_, el) in &els {
236            match el.kind() {
237                ElementKind::Task => {
238                    task_total += 1;
239                    if let Element::Task { data, .. } = el {
240                        if data.is_done() {
241                            task_done += 1;
242                        } else {
243                            task_open += 1;
244                        }
245                    }
246                }
247                ElementKind::Note => notes += 1,
248                ElementKind::Log => {
249                    log_total += 1;
250                    if let Element::Log { data, .. } = el {
251                        if let Some(mins) = data.duration_minutes() {
252                            log_duration += mins as i64;
253                        }
254                    }
255                }
256                ElementKind::Reminder => reminders += 1,
257                ElementKind::Character => characters += 1,
258                ElementKind::MpsGroup | ElementKind::Unknown => {}
259            }
260        }
261    }
262
263    dates.sort();
264    dates.dedup();
265
266    StatsJson {
267        dates,
268        tasks: TaskStats {
269            total: task_total,
270            open: task_open,
271            done: task_done,
272        },
273        notes,
274        logs: LogStats {
275            total: log_total,
276            duration_minutes: log_duration,
277        },
278        reminders,
279        characters,
280    }
281}