Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]#[serde(untagged)]pub enum TaskNotes { Structured(StructuredTaskNotes), Unknown(Value),}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct StructuredTaskNotes { #[serde(rename = "_t", default)] pub object_type: Option<String>, #[serde(rename = "t")] pub format_type: i32, #[serde(default)] pub ch: Option<u32>, #[serde(default)] pub v: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ps: Vec<StructuredTaskNotePatch>, #[serde(flatten)] pub unknown_fields: BTreeMap<String, Value>,}
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct StructuredTaskNotePatch { #[serde(rename = "p")] pub position: usize, #[serde(rename = "l")] pub length: usize, #[serde(rename = "r", default)] pub replacement: String, #[serde(rename = "ch", default)] pub checksum: Option<u32>, #[serde(flatten)] pub unknown_fields: BTreeMap<String, Value>,}
#[derive(Debug, PartialEq, Eq)]pub enum TaskNotesApplyError { UnsupportedFormat(i32), UnknownFormat, InvalidRange { position: usize, length: usize, text_length: usize, }, InvalidUtf8, ChecksumMismatch { expected: u32, actual: u32, },}
impl TaskNotes { pub fn to_plain_text(&self) -> Option<String> { self.apply_to(None).ok().flatten() }
pub fn apply_to(&self, current: Option<&str>) -> Result<Option<String>, TaskNotesApplyError> { match self { Self::Structured(structured) => match structured.format_type { // the app writes an empty note as format 0, with a diag field and no text 0 => Ok(None), 1 => Ok(structured.v.clone().and_then(non_empty)), 2 => apply_patches(current, &structured.ps), format_type => Err(TaskNotesApplyError::UnsupportedFormat(format_type)), }, Self::Unknown(_) => Err(TaskNotesApplyError::UnknownFormat), } }}
fn apply_patches( current: Option<&str>, patches: &[StructuredTaskNotePatch],) -> Result<Option<String>, TaskNotesApplyError> { let mut text = current.unwrap_or_default().as_bytes().to_vec();
for patch in patches { let Some(end) = patch.position.checked_add(patch.length) else { return Err(invalid_range(patch, text.len())); }; if end > text.len() { return Err(invalid_range(patch, text.len())); }
text.splice( patch.position..end, patch.replacement.as_bytes().iter().copied(), );
if std::str::from_utf8(&text).is_err() { return Err(TaskNotesApplyError::InvalidUtf8); }
if let Some(expected) = patch.checksum { let actual = crc32fast::hash(&text); if actual != expected { return Err(TaskNotesApplyError::ChecksumMismatch { expected, actual }); } } }
let text = String::from_utf8(text).map_err(|_| TaskNotesApplyError::InvalidUtf8)?; Ok(non_empty(text))}
fn invalid_range(patch: &StructuredTaskNotePatch, text_length: usize) -> TaskNotesApplyError { TaskNotesApplyError::InvalidRange { position: patch.position, length: patch.length, text_length, }}
fn non_empty(value: String) -> Option<String> { if value.is_empty() { None } else { Some(value) }}
#[cfg(test)]mod tests { use super::*;
fn delta(position: usize, length: usize, replacement: &str, result: &str) -> TaskNotes { TaskNotes::Structured(StructuredTaskNotes { object_type: Some("tx".to_string()), format_type: 2, ch: None, v: None, ps: vec![StructuredTaskNotePatch { position, length, replacement: replacement.to_string(), checksum: Some(crc32fast::hash(result.as_bytes())), unknown_fields: BTreeMap::new(), }], unknown_fields: BTreeMap::new(), }) }
#[test] fn applies_delta_positions_as_utf8_byte_offsets() { let notes = delta(5, 0, "!", "café! todo");
assert_eq!( notes.apply_to(Some("café todo")), Ok(Some("café! todo".to_string())) ); }
#[test] fn applies_historical_suffix_delta_at_its_utf8_byte_offset() { // a delta seen in the journal, one three-byte character before a suffix replaced at byte 605 let prefix = format!("{}’{}", "a".repeat(551), "b".repeat(51)); assert_eq!(prefix.len(), 605); assert_eq!(prefix.chars().count(), 603);
let current = format!("{prefix}{}", "c".repeat(62)); let replacement = "d".repeat(64); let expected = format!("{prefix}{replacement}"); let notes = delta(605, 62, &replacement, &expected);
assert_eq!(notes.apply_to(Some(¤t)), Ok(Some(expected))); }
#[test] fn format_zero_is_an_empty_note() { let notes: TaskNotes = serde_json::from_str(r#"{"_t":"tx","t":0,"diag":"createSet"}"#).expect("notes"); assert_eq!(notes.apply_to(Some("old")), Ok(None)); }
#[test] fn rejects_a_delta_that_splits_a_utf8_character() { let notes = delta(4, 1, "x", "unused");
assert_eq!( notes.apply_to(Some("café")), Err(TaskNotesApplyError::InvalidUtf8) ); }
#[test] fn rejects_a_delta_with_the_wrong_checksum() { let mut notes = delta(0, 4, "done", "done"); let TaskNotes::Structured(structured) = &mut notes else { unreachable!(); }; structured.ps[0].checksum = Some(0);
assert!(matches!( notes.apply_to(Some("todo")), Err(TaskNotesApplyError::ChecksumMismatch { .. }) )); }}