use super::funejson::Value; use super::dates_add_days::days_in_month; pub const MAX_TITLE: usize = 200; pub const MAX_NOTES: usize = 2000; pub const MAX_TAGS: usize = 10; pub const MAX_TAG_LENGTH: usize = 30; pub const MAX_INTERVAL: i64 = 999; const PRIORITIES: [&str; 4] = ["none", "low", "medium", "high"]; const FREQUENCIES: [&str; 5] = ["daily", "weekdays", "weekly", "monthly", "yearly"]; fn is_ascii_space(ch: char) -> bool { matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}') } /// Trim ASCII whitespace only, so "trimmed" means the same in every language. pub fn trim_space(text: &str) -> &str { text.trim_matches(is_ascii_space) } pub fn code_point_length(text: &str) -> usize { text.chars().count() } /// A real calendar date written YYYY-MM-DD, years 0001 to 9999. Never panics. pub fn is_iso_date(text: &str) -> bool { let b = text.as_bytes(); if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { return false; } let digits = |from: usize, to: usize| b[from..to].iter().all(|c| c.is_ascii_digit()); if !digits(0, 4) || !digits(5, 7) || !digits(8, 10) { return false; } let num = |from: usize, to: usize| b[from..to].iter().fold(0i64, |n, c| n * 10 + (c - b'0') as i64); let (year, month, day) = (num(0, 4), num(5, 7), num(8, 10)); year >= 1 && (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month) } fn is_tag_form(tag: &str) -> bool { let b = tag.as_bytes(); if b.is_empty() || b[0] == b'-' || b[b.len() - 1] == b'-' { return false; } for (i, &c) in b.iter().enumerate() { let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || (c == b'-' && b[i - 1] != b'-'); if !ok { return false; } } true } fn title_error(title: &str) -> Option { let trimmed = trim_space(title); if trimmed.is_empty() { return Some("Enter a title.".to_string()); } if trimmed.chars().any(|ch| (ch as u32) < 0x20 || ch as u32 == 0x7F) { return Some("Keep the title to one line, without control characters.".to_string()); } if code_point_length(trimmed) > MAX_TITLE { return Some(format!("Use no more than {} characters for the title.", MAX_TITLE)); } None } fn tags_error(tags: &[String]) -> Option { if tags.len() > MAX_TAGS { return Some(format!("Use no more than {} tags.", MAX_TAGS)); } let mut seen: Vec<&str> = Vec::new(); for tag in tags { if !is_tag_form(tag) { return Some(format!("Tag \"{}\" can only use lower-case letters, digits and single hyphens.", tag)); } if tag.len() > MAX_TAG_LENGTH { return Some(format!("Tag \"{}\" is longer than {} characters.", tag, MAX_TAG_LENGTH)); } if seen.contains(&tag.as_str()) { return Some(format!("Tag \"{}\" is listed twice.", tag)); } seen.push(tag); } None } fn recurrence_error(rule: Option<&Recurrence>, due: Option<&str>) -> Option { let rule = rule?; if !FREQUENCIES.contains(&rule.frequency.as_str()) { return Some("Choose how it repeats: daily, weekdays, weekly, monthly or yearly.".to_string()); } if rule.interval < 1 || rule.interval > MAX_INTERVAL { return Some(format!("Repeat every 1 to {} days, weeks, months or years.", MAX_INTERVAL)); } if rule.frequency == "weekdays" && rule.interval != 1 { return Some("Weekday repeats cannot skip weeks; use an interval of 1.".to_string()); } if !is_iso_date(&rule.anchor) { return Some("Enter the repeat start as a real date, YYYY-MM-DD.".to_string()); } match due { None => Some("A repeating todo needs a due date.".to_string()), Some(d) if is_iso_date(d) && rule.anchor.as_str() > d => { Some("The repeat cannot start after the due date.".to_string()) } _ => None, } } /// The edit rules as ordered (field, message) pairs, from the draft's fields, /// so validate_todo can run them on a stored Todo too. pub fn draft_errors( title: &str, notes: Option<&str>, priority: &str, due: Option<&str>, tags: &[String], recurrence: Option<&Recurrence>, ) -> Vec<(String, String)> { let mut errors: Vec<(String, String)> = Vec::new(); if let Some(message) = title_error(title) { errors.push(("title".to_string(), message)); } if let Some(n) = notes { if code_point_length(n) > MAX_NOTES { errors.push(("notes".to_string(), format!("Use no more than {} characters for the notes.", MAX_NOTES))); } } if !PRIORITIES.contains(&priority) { errors.push(("priority".to_string(), "Choose a priority: none, low, medium or high.".to_string())); } if let Some(d) = due { if !is_iso_date(d) { errors.push(("due".to_string(), "Enter the due date as a real date, YYYY-MM-DD.".to_string())); } } if let Some(message) = tags_error(tags) { errors.push(("tags".to_string(), message)); } if let Some(message) = recurrence_error(recurrence, due) { errors.push(("recurrence".to_string(), message)); } errors } /// Check what a person typed into the add or edit form; the title is judged trimmed. pub fn validate_draft(draft: &TodoDraft) -> TodoValidation { let errors = draft_errors( &draft.title, draft.notes.as_deref(), &draft.priority, draft.due.as_deref(), &draft.tags, draft.recurrence.as_ref(), ); TodoValidation { valid: errors.is_empty(), errors } } fn opt_text(v: &Value) -> Option { if v.is_null() { None } else { Some(v.as_str().to_string()) } } fn opt_value(v: &Option) -> Value { v.as_deref().map(Value::str).unwrap_or(Value::Null) } pub fn texts_from_value(v: &Value) -> Vec { v.as_arr().iter().map(|t| t.as_str().to_string()).collect() } pub fn recurrence_from_value(v: &Value) -> Option { if v.is_null() { return None; } Some(Recurrence { frequency: v.get("frequency").as_str().to_string(), interval: v.get("interval").as_i64(), anchor: v.get("anchor").as_str().to_string(), }) } pub fn recurrence_to_value(rule: &Option) -> Value { match rule { None => Value::Null, Some(r) => Value::obj(vec![ ("frequency", Value::str(&r.frequency)), ("interval", Value::Int(r.interval)), ("anchor", Value::str(&r.anchor)), ]), } } pub fn todo_draft_from_value(v: &Value) -> TodoDraft { TodoDraft { title: v.get("title").as_str().to_string(), notes: opt_text(v.get("notes")), priority: v.get("priority").as_str().to_string(), due: opt_text(v.get("due")), tags: texts_from_value(v.get("tags")), recurrence: recurrence_from_value(v.get("recurrence")), } } pub fn todo_draft_to_value(draft: &TodoDraft) -> Value { Value::obj(vec![ ("title", Value::str(&draft.title)), ("notes", opt_value(&draft.notes)), ("priority", Value::str(&draft.priority)), ("due", opt_value(&draft.due)), ("tags", Value::Arr(draft.tags.iter().map(|t| Value::str(t)).collect())), ("recurrence", recurrence_to_value(&draft.recurrence)), ]) } pub fn todo_validation_to_value(result: &TodoValidation) -> Value { Value::obj(vec![ ("valid", Value::Bool(result.valid)), ("errors", Value::Obj(result.errors.iter().map(|(k, m)| (k.clone(), Value::str(m))).collect())), ]) } pub fn fune_vector(args: &[Value]) -> Value { todo_validation_to_value(&validate_draft(&todo_draft_from_value(&args[0]))) }