1use std::borrow::Cow;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use arrow::array::ArrayRef;
7use arrow::array::RecordBatch;
8use arrow::array::StringArray;
9use arrow::util::display::ArrayFormatter;
10use bevy_math::DVec2;
11use chrono::DateTime;
12use chrono::NaiveDateTime;
13use chrono::NaiveTime;
14use chrono::Utc;
15use serde::Deserialize;
16use serde::Deserializer;
17use serde::Serialize;
18use serde::Serializer;
19use serde::de::Error;
20use tracing::info;
21use tracing::warn;
22
23#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Default)]
24#[serde(untagged)]
25pub enum UserValue {
26 #[default]
27 #[serde(serialize_with = "serde::ser::Serializer::serialize_none")]
28 None,
29 DateTime(NaiveDateTime),
31 Time(NaiveTime),
32 Duration(Duration),
33 Text(String),
34 Number(f64),
35 Bool(bool),
36 Files(Vec<PathBuf>),
37 Notes(Vec<Note>),
38 Table(TableData),
39 GeoPos(GeoPos),
40 PlotRegions(Vec<PlotRegion>),
41}
42
43#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
44pub struct Note {
45 timestamp: DateTime<Utc>,
46 pub text: String,
47}
48
49impl Note {
50 pub fn new(text: impl Into<String>) -> Self {
51 Self::with_timestamp(Utc::now(), text.into())
52 }
53 pub fn with_timestamp(timestamp: DateTime<Utc>, text: impl Into<String>) -> Self {
54 Self {
55 timestamp,
56 text: text.into(),
57 }
58 }
59
60 pub fn preview(&self) -> Cow<'_, str> {
61 if let Some((i, _)) = self.text.char_indices().nth(8) {
62 Cow::Borrowed(&self.text[..i])
63 } else {
64 Cow::Owned(self.timestamp.with_timezone(&chrono::Local).to_string())
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq)]
70pub struct TableData {
71 pub batch: RecordBatch,
72}
73
74#[derive(Deserialize, Serialize, Debug, Clone)]
75struct JsonTableData {
76 #[serde(default)]
77 pub columns: Vec<String>,
78 #[serde(deserialize_with = "deserialize_string_array", default)]
79 pub data: Vec<Vec<String>>,
80}
81
82impl<'w> Deserialize<'w> for TableData {
83 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84 where
85 D: Deserializer<'w>,
86 {
87 let data = JsonTableData::deserialize(deserializer)?;
88
89 info!(
90 "Parsed table data: {} columns, {} rows",
91 data.columns.len(),
92 data.data.len()
93 );
94
95 if data.columns.len() != data.data.len() {
96 warn!(
97 "number of columns does not match shape of data: {} != {}",
98 data.columns.len(),
99 data.data.len()
100 );
101 }
102
103 let columns = std::iter::zip(data.data, data.columns)
104 .map(|(column, colname)| (colname, Arc::new(StringArray::from(column)) as ArrayRef));
105
106 let batch = RecordBatch::try_from_iter(columns).map_err(D::Error::custom)?;
107 Ok(Self { batch })
108 }
109}
110
111impl Serialize for TableData {
112 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
113 where
114 S: serde::Serializer,
115 {
116 let columns = self
117 .batch
118 .schema_ref()
119 .fields()
120 .iter()
121 .map(|field| field.name().clone())
122 .collect();
123 let data = self
124 .batch
125 .columns()
126 .iter()
127 .map(|col| {
128 let formatter = ArrayFormatter::try_new(col, &Default::default()).unwrap();
129 (0..col.len())
130 .map(|i| formatter.value(i).to_string())
131 .collect()
132 })
133 .collect();
134 let value = JsonTableData { columns, data };
135 value.serialize(serializer)
136 }
137}
138
139pub fn deserialize_string_array<'de, D>(deserializer: D) -> Result<Vec<Vec<String>>, D::Error>
140where
141 D: Deserializer<'de>,
142{
143 let raw_data: Vec<Vec<Option<String>>> = Vec::deserialize(deserializer)?;
144 Ok(raw_data
145 .into_iter()
146 .map(|row| {
147 row.into_iter()
148 .map(|cell| cell.unwrap_or_default())
149 .collect()
150 })
151 .collect())
152}
153
154#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
155pub struct PlotRegion {
156 pub start_timestamp: f64,
157 pub end_timestamp: f64,
158 pub color: Color,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq)]
162pub struct Color(egui::Color32);
163
164impl Color {
165 pub const fn as_egui_color(self) -> egui::Color32 {
166 self.0
167 }
168}
169
170impl From<Color> for egui::Color32 {
171 fn from(value: Color) -> Self {
172 value.as_egui_color()
173 }
174}
175
176impl<'de> Deserialize<'de> for Color {
177 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
178 where
179 D: Deserializer<'de>,
180 {
181 let input = String::deserialize(deserializer)?;
182 let hex = input.trim().to_lowercase();
183
184 if !hex.starts_with('#') {
185 return Err(D::Error::custom(
186 "hex colors must start with the `#` character",
187 ));
188 }
189
190 let color = match egui::Color32::from_hex(&hex) {
191 Ok(color) => color,
192 Err(egui::ecolor::ParseHexColorError::MissingHash) => {
193 return Err(D::Error::custom("hex color is missing `#` prefix"));
195 }
196 Err(egui::ecolor::ParseHexColorError::InvalidLength) => {
197 return Err(D::Error::custom(
198 "hex color has invalid length — must be 7 or 9 characters (e.g., `#RRGGBB` or `#RRGGBBAA`)",
199 ));
200 }
201 Err(egui::ecolor::ParseHexColorError::InvalidInt(e)) => {
202 return Err(D::Error::custom(format!("failed to parse hex digits: {e}")));
203 }
204 };
205
206 Ok(Color(color))
207 }
208}
209
210impl Serialize for Color {
211 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
212 where
213 S: Serializer,
214 {
215 let hex_string = format!(
216 "#{:02x}{:02x}{:02x}{:02x}",
217 self.0.r(),
218 self.0.g(),
219 self.0.b(),
220 self.0.a()
221 );
222 serializer.serialize_str(&hex_string)
223 }
224}
225
226#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Default)]
227pub struct GeoPos {
228 pub latitude: f64,
229 pub longitude: f64,
230}
231
232impl From<DVec2> for GeoPos {
233 fn from(value: DVec2) -> Self {
234 Self {
235 latitude: value.y,
236 longitude: value.x,
237 }
238 }
239}
240
241impl From<GeoPos> for DVec2 {
242 fn from(value: GeoPos) -> Self {
243 Self {
244 x: value.longitude,
245 y: value.latitude,
246 }
247 }
248}
249
250impl std::fmt::Display for GeoPos {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 write!(f, "{},{}", self.latitude, self.longitude)
253 }
254}
255
256const DATE_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
257const TIME_FORMAT: &str = "%H:%M:%S";
258
259impl UserValue {
260 pub fn string_mut(&mut self) -> &mut String {
261 match *self {
262 Self::None => self.insert_string(String::new()),
263 Self::DateTime(ref value) => self.insert_string(format!("{value:?}")),
264 Self::Time(ref value) => self.insert_string(format!("{value:?}")),
265 Self::Duration(ref value) => self.insert_string(format!("{value:?}")),
266 Self::Text(ref mut value) => value,
267 Self::Number(value) => self.insert_string(value.to_string()),
268 Self::Bool(value) => self.insert_string(value.to_string()),
269 Self::Files(ref files) => {
270 self.insert_string(fold_str(files.iter().map(|p| p.to_string_lossy())))
271 }
272 Self::Notes(ref notes) => self.insert_string(fold_str(notes.iter().map(|n| &n.text))),
273 Self::Table(ref table) => {
274 self.insert_string(serde_json::to_string(table).unwrap_or_else(|e| e.to_string()))
275 }
276 Self::GeoPos(ref geo_pos) => self.insert_string(geo_pos.to_string()),
277 Self::PlotRegions(ref regions) => {
278 self.insert_string(serde_json::to_string(regions).unwrap_or_else(|e| e.to_string()))
279 }
280 }
281 }
282
283 pub fn number_mut(&mut self, fallback: f64) -> &mut f64 {
284 match *self {
285 Self::Number(ref mut value) => value,
286 Self::Text(ref value) => {
287 let value = value.parse::<f64>().unwrap_or(fallback);
288 self.insert_number(value)
289 }
290 Self::Bool(value) => self.insert_number(if value { 1.0 } else { 0.0 }),
291 Self::None
292 | Self::Files(_)
293 | Self::Notes(_)
294 | Self::Table(_)
295 | Self::PlotRegions(_)
296 | Self::DateTime(_)
297 | Self::Time(_)
298 | Self::Duration(_)
299 | Self::GeoPos(_) => self.insert_number(fallback),
300 }
301 }
302
303 pub fn bool_mut(&mut self, fallback: bool) -> &mut bool {
304 match *self {
305 Self::Bool(ref mut value) => value,
306 Self::Text(ref value) => {
307 let value = value.to_lowercase().parse::<bool>().unwrap_or(fallback);
308 self.insert_bool(value)
309 }
310 Self::Number(value) => self.insert_bool(value != 0.0),
311 Self::None
312 | Self::Files(_)
313 | Self::Notes(_)
314 | Self::Table(_)
315 | Self::PlotRegions(_)
316 | Self::DateTime(_)
317 | Self::Time(_)
318 | Self::Duration(_)
319 | Self::GeoPos(_) => self.insert_bool(fallback),
320 }
321 }
322
323 pub fn files_mut(&mut self, fallback: impl FnOnce() -> Vec<PathBuf>) -> &mut Vec<PathBuf> {
324 match *self {
325 Self::Files(ref mut files) => files,
326 _ => self.insert_files(fallback()),
327 }
328 }
329
330 pub fn notes_mut(&mut self, fallback: impl FnOnce() -> Vec<Note>) -> &mut Vec<Note> {
331 match *self {
332 Self::Notes(ref mut notes) => notes,
333 _ => self.insert_notes(fallback()),
334 }
335 }
336
337 pub fn table_mut(&mut self, fallback: impl FnOnce() -> TableData) -> &mut TableData {
338 match *self {
339 Self::Table(ref mut table) => table,
340 _ => self.insert_table(fallback()),
341 }
342 }
343
344 pub fn geo_pos_mut(&mut self, fallback: impl FnOnce() -> GeoPos) -> &mut GeoPos {
345 match *self {
346 Self::GeoPos(ref mut geo_pos) => geo_pos,
347 _ => self.insert_geo_pos(fallback()),
348 }
349 }
350
351 pub fn as_bool(&self) -> Option<bool> {
352 match *self {
353 Self::Bool(value) => Some(value),
354 Self::Text(ref value) => value.to_lowercase().parse::<bool>().ok(),
355 Self::Number(value) => Some(value != 0.0),
356 Self::None
357 | Self::Files(_)
358 | Self::Notes(_)
359 | Self::Table(_)
360 | Self::PlotRegions(_)
361 | Self::DateTime(_)
362 | Self::Time(_)
363 | Self::Duration(_)
364 | Self::GeoPos(_) => None,
365 }
366 }
367
368 pub fn as_table(&self) -> Option<&TableData> {
369 match *self {
370 Self::Table(ref value) => Some(value),
371 _ => None,
372 }
373 }
374
375 pub fn as_str(&self) -> Option<Cow<'_, str>> {
376 match *self {
377 Self::Text(ref text) => Some(Cow::Borrowed(text)),
378 Self::None => Some(Cow::Borrowed("")),
379 Self::Bool(value) => Some(Cow::Owned(value.to_string())),
380 Self::Number(value) => Some(Cow::Owned(value.to_string())),
381 Self::DateTime(value) => Some(Cow::Owned(value.to_string())),
382 Self::Time(value) => Some(Cow::Owned(value.to_string())),
383 Self::Duration(value) => Some(Cow::Owned(format!("{value:?}"))),
384 Self::GeoPos(value) => Some(Cow::Owned(value.to_string())),
385 Self::Files(_) | Self::Notes(_) | Self::Table(_) | Self::PlotRegions(_) => None,
386 }
387 }
388
389 pub fn as_date_time(&self) -> Option<NaiveDateTime> {
390 match *self {
391 Self::DateTime(value) => Some(value),
392 Self::Text(ref text) => NaiveDateTime::parse_from_str(text, DATE_TIME_FORMAT).ok(),
393 Self::Number(timestamp) => chrono::DateTime::from_timestamp(timestamp as i64, 0)
394 .map(|dt| dt.with_timezone(&chrono::Local).naive_local()),
395 _ => None,
396 }
397 }
398
399 pub fn as_time(&self) -> Option<NaiveTime> {
400 match *self {
401 Self::Time(value) => Some(value),
402 Self::Text(ref text) => NaiveTime::parse_from_str(text, TIME_FORMAT).ok(),
403 Self::Number(timestamp) => chrono::DateTime::from_timestamp(timestamp as i64, 0)
404 .map(|dt| dt.with_timezone(&chrono::Local).naive_local().time()),
405 _ => None,
406 }
407 }
408
409 pub fn as_geo_pos(&self) -> Option<GeoPos> {
410 match *self {
411 Self::GeoPos(value) => Some(value),
412 _ => None,
413 }
414 }
415
416 pub fn as_plot_regions(&self) -> Option<&[PlotRegion]> {
417 match *self {
418 Self::PlotRegions(ref regions) => Some(regions),
419 _ => None,
420 }
421 }
422
423 pub fn insert_string(&mut self, value: String) -> &mut String {
424 *self = Self::Text(value);
425 match self {
426 Self::Text(value) => value,
427 _ => unreachable!(),
428 }
429 }
430 pub fn insert_number(&mut self, value: f64) -> &mut f64 {
431 *self = Self::Number(value);
432 match self {
433 Self::Number(value) => value,
434 _ => unreachable!(),
435 }
436 }
437 pub fn insert_bool(&mut self, value: bool) -> &mut bool {
438 *self = Self::Bool(value);
439 match self {
440 Self::Bool(value) => value,
441 _ => unreachable!(),
442 }
443 }
444 pub fn insert_files(&mut self, value: Vec<PathBuf>) -> &mut Vec<PathBuf> {
445 *self = Self::Files(value);
446 match self {
447 Self::Files(value) => value,
448 _ => unreachable!(),
449 }
450 }
451 pub fn insert_notes(&mut self, value: Vec<Note>) -> &mut Vec<Note> {
452 *self = Self::Notes(value);
453 match self {
454 Self::Notes(value) => value,
455 _ => unreachable!(),
456 }
457 }
458 pub fn insert_table(&mut self, value: TableData) -> &mut TableData {
459 *self = Self::Table(value);
460 match self {
461 Self::Table(value) => value,
462 _ => unreachable!(),
463 }
464 }
465 pub fn insert_datetime(&mut self, value: NaiveDateTime) -> &mut NaiveDateTime {
466 *self = Self::DateTime(value);
467 match self {
468 Self::DateTime(value) => value,
469 _ => unreachable!(),
470 }
471 }
472 pub fn insert_time(&mut self, value: NaiveTime) -> &mut NaiveTime {
473 *self = Self::Time(value);
474 match self {
475 Self::Time(value) => value,
476 _ => unreachable!(),
477 }
478 }
479 pub fn insert_geo_pos(&mut self, value: GeoPos) -> &mut GeoPos {
480 *self = Self::GeoPos(value);
481 match self {
482 Self::GeoPos(value) => value,
483 _ => unreachable!(),
484 }
485 }
486}
487
488impl From<String> for UserValue {
489 fn from(value: String) -> Self {
490 Self::Text(value)
491 }
492}
493
494impl From<f32> for UserValue {
495 fn from(value: f32) -> Self {
496 Self::Number(value.into())
497 }
498}
499
500impl From<f64> for UserValue {
501 fn from(value: f64) -> Self {
502 Self::Number(value)
503 }
504}
505
506impl From<bool> for UserValue {
507 fn from(value: bool) -> Self {
508 Self::Bool(value)
509 }
510}
511
512impl From<NaiveDateTime> for UserValue {
513 fn from(value: NaiveDateTime) -> Self {
514 Self::DateTime(value)
515 }
516}
517
518impl From<NaiveTime> for UserValue {
519 fn from(value: NaiveTime) -> Self {
520 Self::Time(value)
521 }
522}
523
524impl From<Duration> for UserValue {
525 fn from(value: Duration) -> Self {
526 Self::Duration(value)
527 }
528}
529
530impl From<GeoPos> for UserValue {
531 fn from(value: GeoPos) -> Self {
532 Self::GeoPos(value)
533 }
534}
535
536fn fold_str<S: Into<String> + AsRef<str>>(iter: impl IntoIterator<Item = S>) -> String {
537 iter.into_iter().fold(String::new(), |mut value, str| {
538 if value.is_empty() {
539 str.into()
540 } else {
541 value.push('\n');
542 value.push_str(str.as_ref());
543 value
544 }
545 })
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn serialize_none() {
554 assert_eq!(
555 serde_yaml::to_value(UserValue::None).unwrap(),
556 serde_yaml::Value::Null
557 );
558 assert_eq!(
559 UserValue::None,
560 serde_yaml::from_value(serde_yaml::Value::Null).unwrap(),
561 );
562 }
563
564 #[test]
565 fn serialize_date_time() {
566 let date_time =
567 NaiveDateTime::parse_from_str("2025-5-15T4:46:12", DATE_TIME_FORMAT).unwrap();
568
569 assert_eq!(
570 serde_yaml::to_value(UserValue::DateTime(date_time)).unwrap(),
571 serde_yaml::Value::String("2025-05-15T04:46:12".to_owned())
572 );
573 assert_eq!(
574 UserValue::DateTime(date_time),
575 serde_yaml::from_str("2025-05-15T04:46:12").unwrap()
576 );
577 }
578
579 #[test]
580 fn serialize_time() {
581 let date_time = NaiveTime::parse_from_str("04:46:12", TIME_FORMAT).unwrap();
582
583 assert_eq!(
584 serde_yaml::to_value(UserValue::Time(date_time)).unwrap(),
585 serde_yaml::Value::String("04:46:12".to_owned())
586 );
587 assert_eq!(
588 UserValue::Time(date_time),
589 serde_yaml::from_str("04:46:12").unwrap()
590 );
591 }
592
593 #[test]
594 #[should_panic = "data did not match any variant"]
595 fn deserialize_error() {
596 let _: UserValue = serde_yaml::from_value(serde_yaml::Value::Mapping(
597 serde_yaml::Mapping::from_iter([
598 (
599 serde_yaml::Value::String("foo".into()),
600 serde_yaml::Value::String("bar".into()),
601 ),
602 ]),
604 ))
605 .unwrap();
606 }
607}