Something went wrong. Try again.
forked from evanpurkhiser/things3-cloud
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667use std::{collections::HashSet, fmt::Write as _, io::Write as _};
use chrono::{DateTime, Local, NaiveDate, NaiveTime, TimeZone, Timelike, Utc};use crc32fast::Hasher;
use crate::{ ids::ThingsId, store::{Tag, Task, ThingsStore}, wire::{ notes::{StructuredTaskNotes, TaskNotes}, task::TaskType, },};
/// today, the local calendar day at UTC midnightpub fn today_utc() -> DateTime<Utc> { local_date_as_utc_midnight(Local::now())}
fn local_date_as_utc_midnight<Tz: TimeZone>(now: DateTime<Tz>) -> DateTime<Utc> { let today = now.date_naive().and_hms_opt(0, 0, 0).unwrap(); Utc.from_utc_datetime(&today)}
/// the current wall-clock time in fractional unix secondspub fn now_ts_f64() -> f64 { Utc::now().timestamp_millis() as f64 / 1000.0}
pub const RESET: &str = "\x1b[0m";pub const DIM: &str = "\x1b[2m";pub const GREEN: &str = "\x1b[32m";
pub struct Icons { // sidebar and view icons pub inbox: &'static str, pub today: &'static str, pub upcoming: &'static str, pub anytime: &'static str, pub find: &'static str,
// task and grouping icons pub task_open: &'static str, pub task_done: &'static str, pub task_someday: &'static str, pub task_canceled: &'static str, pub today_staged: &'static str, pub project: &'static str, pub project_someday: &'static str, pub area: &'static str, pub tag: &'static str, pub evening: &'static str, pub repeat: &'static str,
// project progress icons pub progress_empty: &'static str, pub progress_quarter: &'static str, pub progress_half: &'static str, pub progress_three_quarter: &'static str, pub progress_full: &'static str,
// status and event icons pub deadline: &'static str, pub done: &'static str, pub incomplete: &'static str, pub canceled: &'static str, pub deleted: &'static str,
// checklist icons pub checklist_open: &'static str, pub checklist_done: &'static str, pub checklist_canceled: &'static str,}
pub const ICONS: Icons = Icons { inbox: "⬓", today: "⭑", upcoming: "▷", anytime: "≋", find: "⌕",
task_open: "▢", task_done: "◼", task_someday: "⬚", task_canceled: "☒", today_staged: "●", project: "●", project_someday: "◌", area: "◆", tag: "⌗", evening: "☽", repeat: "↻",
progress_empty: "◯", progress_quarter: "◔", progress_half: "◑", progress_three_quarter: "◕", progress_full: "◉",
deadline: "⚑", done: "✓", incomplete: "↺", canceled: "☒", deleted: "×",
checklist_open: "○", checklist_done: "●", checklist_canceled: "×",};
/// a line of diagnostics on stderr////// a reader that closed stderr takes the line with it/// no other channel is left to report thatpub fn eprint_line(text: &str) { let _ = writeln!(std::io::stderr(), "{text}");}
/// a count with its noun, singular for onepub fn counted(count: usize, noun: &str) -> String { let plural = if count == 1 { "" } else { "s" }; format!("{count} {noun}{plural}")}
/// NO_COLOR with any value but the empty string asks for output without colorpub fn no_color_requested() -> bool { std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty())}
pub fn colored<T: ToString>(text: T, codes: &[&str], no_color: bool) -> String { let text = text.to_string(); if no_color { return text; } let mut out = String::new(); for code in codes { out.push_str(code); } out.push_str(&text); out.push_str(RESET); out}
pub fn fmt_date(dt: Option<DateTime<Utc>>) -> String { dt.map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_default()}
/// the local calendar day of an instant, with the offset in force at that instant rather than today'spub fn fmt_date_local(dt: Option<DateTime<Utc>>) -> String { dt.map(|d| d.with_timezone(&Local).format("%Y-%m-%d").to_string()) .unwrap_or_default()}
/// a calendar day as the wire stores it, the unix timestamp of 00:00 UTC on that daypub fn day_timestamp(day: NaiveDate) -> i64 { day.and_hms_opt(0, 0, 0) .expect("midnight") .and_utc() .timestamp()}
/// the calendar day a wire day timestamp namespub fn day_of(timestamp: i64) -> Option<NaiveDate> { DateTime::from_timestamp(timestamp, 0).map(|day| day.date_naive())}
/// a YYYY-MM-DD flag as the calendar day it names////// no time zone takes part/// the day goes on the wire at UTC midnight through `day_timestamp`/// the local offset of today or of that day cannot move it to the day beforepub fn parse_day(day: &str, label: &str) -> Result<NaiveDate, String> { NaiveDate::parse_from_str(day, "%Y-%m-%d") .map_err(|_| format!("Invalid {label} date: {day} (expected YYYY-MM-DD)"))}
/// an RFC 3339 instant, or a YYYY-MM-DD day taken at local midnight, the instant that day began where the command runs, as a wire timestamp////// an instant, unlike a day stamp, is shown under its local day/// a day given for one has to fall therepub fn parse_instant(text: &str, label: &str) -> Result<f64, String> { if let Ok(instant) = DateTime::parse_from_rfc3339(text) { return Ok(instant.timestamp_millis() as f64 / 1000.0); } let day = parse_day(text, label) .map_err(|_| format!("Invalid {label}: {text} (expected RFC 3339 or YYYY-MM-DD)"))?; Local .from_local_datetime(&day.and_hms_opt(0, 0, 0).expect("midnight")) .earliest() .map(|instant| instant.timestamp() as f64) .ok_or_else(|| format!("Invalid {label}: {text} has no midnight in the local time zone"))}
pub fn parse_reminder(time: &str) -> Result<i64, String> { NaiveTime::parse_from_str(time, "%H:%M") .map(|t| i64::from(t.num_seconds_from_midnight())) .map_err(|_| format!("Invalid --reminder time: {time} (expected HH:MM)"))}
/// how `sanitized` shows the text it is given#[derive(PartialEq)]enum Shown { /// the output on a terminal that shows colors /// /// the CLI's own SGR sequences pass Colored, /// text without colors of its own /// /// errors and output off a terminal Plain, /// JSON /// /// its escapes decode to the same text Json, /// one field of cloud text /// /// kept on its line Field,}
/// the finished output on a terminal that shows colors////// the CLI's own SGR colors pass/// other C0 controls and DEL show in caret notation/// C1 and the bidi controls show as U+FFFD/// titles and notes come from the cloud/// anyone who can mail a to-do into the Inbox writes them there/// the text they add passes `one_line` before it gets here/// that keeps their escape sequences outpub fn printable(text: &str) -> String { sanitized(text, Shown::Colored)}
/// errors and output without colors////// no escape sequence passes/// the CLI adds nonepub fn printable_plain(text: &str) -> String { sanitized(text, Shown::Plain)}
/// JSON for the terminal////// the controls `serde_json` writes raw leave as `\u` escapes that decode to the same textpub fn printable_json(text: &str) -> String { sanitized(text, Shown::Json)}
/// one field of cloud text, a title for instance, for a line of terminal output////// every control character shows escaped, line breaks and escape sequences includedpub fn one_line(text: &str) -> String { sanitized(text, Shown::Field)}
/// a title as the lists show it, "(untitled)" when it holds nothingpub fn shown_title(title: &str) -> String { if title.trim().is_empty() { "(untitled)".to_string() } else { one_line(title) }}
/// a note from the cloud for the terminal////// each line passes `one_line`/// the layout then breaks the note at its own line breaks alonepub fn note_lines(text: &str) -> String { text.lines().map(one_line).collect::<Vec<_>>().join("\n")}
fn sanitized(text: &str, shown: Shown) -> String { let mut out = String::with_capacity(text.len()); let mut skip_to = 0; for (at, c) in text.char_indices() { if at < skip_to { continue; } match c { '\n' | '\t' if shown != Shown::Field => out.push(c), '\u{1b}' if matches!(shown, Shown::Colored | Shown::Json) => match sgr(&text[at..]) { Some(sequence) => { out.push_str(sequence); skip_to = at + sequence.len(); } None => out.push_str("^["), }, // serde_json escapes C0 alone // DEL, C1 and the bidi controls stand raw in its strings c if shown == Shown::Json && (c == '\u{7f}' || replaced(c)) => { let _ = write!(out, "\\u{:04x}", u32::from(c)); } '\u{7f}' => out.push_str("^?"), c if c.is_ascii_control() => { out.push('^'); out.push(char::from(c as u8 + 0x40)); } c if replaced(c) => out.push('\u{fffd}'), // the line and paragraph separators end a row in the layout '\u{2028}' | '\u{2029}' if shown == Shown::Field => out.push('\u{fffd}'), c => out.push(c), } } out}
/// C1, and the embedding, override and isolate controls that reorder the text around them on a terminal that applies bidifn replaced(c: char) -> bool { matches!(c, '\u{80}'..='\u{9f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')}
/// the select graphic rendition sequence at the start of `text`fn sgr(text: &str) -> Option<&str> { let params = text.strip_prefix("\u{1b}[")?; let len = params.find(|c: char| !c.is_ascii_digit() && c != ';')?; params[len..].starts_with('m').then(|| &text[..len + 3])}
pub fn task6_note(value: &str) -> TaskNotes { let mut hasher = Hasher::new(); hasher.update(value.as_bytes()); let checksum = hasher.finalize(); TaskNotes::Structured(StructuredTaskNotes { object_type: Some("tx".to_string()), format_type: 1, ch: Some(checksum), v: Some(value.to_string()), ps: Vec::new(), unknown_fields: Default::default(), })}
/// the project or area a to-do goes intopub enum Container { Project(ThingsId), Area(ThingsId),}
/// an item named by its kind with its article////// a kind this CLI does not know is named by its number, as `show` names itpub fn kind_with_article(task: &Task) -> String { match task.item_type { TaskType::Todo => "a to-do".to_string(), TaskType::Project => "a project".to_string(), TaskType::Heading => "a heading".to_string(), TaskType::Unknown(raw) => format!("an item of unknown kind {raw}"), }}
/// the message for a target that several items or areas share, None while one at most matches////// a resolved item or area counts once/// an ambiguous prefix counts every candidatepub fn ambiguous_target(flag: &str, target: &str, items: usize, areas: usize) -> Option<String> { let matches = [(items, "item"), (areas, "area")] .into_iter() .filter(|(count, _)| *count > 0) .map(|(count, noun)| counted(count, noun)) .collect::<Vec<_>>(); (items + areas > 1).then(|| { format!( "Ambiguous {flag} target '{target}' (matches {}).", matches.join(" and ") ) })}
/// the project or area `target` names for a to-do, or why a to-do cannot go there////// `flag` names the option in messagespub fn resolve_container( store: &ThingsStore, target: &str, flag: &str,) -> Result<Container, String> { let (item, _, item_candidates) = store.resolve_task_identifier(target); let (area, _, area_candidates) = store.resolve_area_identifier(target); let items = if item.is_some() { 1 } else { item_candidates.len() }; let areas = if area.is_some() { 1 } else { area_candidates.len() }; if let Some(message) = ambiguous_target(flag, target, items, areas) { return Err(message); } let project = match (item, area) { // an area that did not replay completely may not be what it shows (None, Some(area)) if area.degraded => { return Err(format!( "Container did not replay completely: {}", shown_title(&area.title) )); } (None, Some(area)) => return Ok(Container::Area(area.uuid)), (None, None) => return Err(format!("Container not found: {target}")), (Some(project), _) if project.is_project() => project, (Some(item), _) => { return Err(format!( "Container is {}: {}", kind_with_article(&item), shown_title(&item.title) )); } }; if let Some(refusal) = container_refusal(store, &project) { return Err(refusal); } Ok(Container::Project(project.uuid))}
/// why a project or heading takes no to-do, None when it takes onepub fn container_refusal(store: &ThingsStore, container: &Task) -> Option<String> { // a project or a heading holds to-dos // an item of any other kind holds none if !container.is_project() && !container.is_heading() { return Some(format!( "Container is {}: {}", kind_with_article(container), shown_title(&container.title) )); } // a container of a kind the writes do not know may be closed or in the Trash without showing it if !container.entity.can_upgrade_to_task7() { return Some(format!( "Container is of kind {}: {}", container.entity, shown_title(&container.title) )); } // one that did not replay completely may be as well if container.degraded { return Some(format!( "Container did not replay completely: {}", shown_title(&container.title) )); } // a closed container lists no incomplete to-do // one placed there would show nowhere if let Some(state) = store.closed_state(container) { return Some(format!( "Container is {state}: {}", shown_title(&container.title) )); } // the template of a repeating project shows in no list // a to-do placed there would show nowhere either if container.is_recurrence_template() { return Some(format!( "Container is a repeat template: {}", shown_title(&container.title) )); } None}
pub fn resolve_single_tag(store: &ThingsStore, identifier: &str) -> (Option<Tag>, String) { // the whole argument names one tag, whatever commas its title holds let all_tags: Vec<Tag> = store.tags_by_uuid.values().cloned().collect(); match resolve_single_tag_id(&all_tags, identifier) { Ok(id) => (store.tags_by_uuid.get(&id).cloned(), String::new()), Err(err) => (None, err), }}
pub fn resolve_tag_ids(store: &ThingsStore, raw_tags: &str) -> (Vec<ThingsId>, String) { resolve_tags(store, raw_tags, false)}
/// the tags a removal names////// a tag deleted before its carriers were cleared leaves its id on them/// the full id of such a tag still names it while something carries itpub fn resolve_removable_tag_ids(store: &ThingsStore, raw_tags: &str) -> (Vec<ThingsId>, String) { resolve_tags(store, raw_tags, true)}
fn resolve_tags(store: &ThingsStore, raw_tags: &str, removable: bool) -> (Vec<ThingsId>, String) { let tokens = raw_tags .split(',') .map(str::trim) .filter(|t| !t.is_empty()) .collect::<Vec<_>>(); if tokens.is_empty() { return (Vec::new(), String::new()); }
// a tag of a blank title still resolves by its id let all_tags: Vec<Tag> = store.tags_by_uuid.values().cloned().collect(); let mut resolved = Vec::new(); let mut seen = HashSet::new();
for token in tokens { let tag_uuid = match resolve_single_tag_id(&all_tags, token) { Ok(tag_uuid) => tag_uuid, Err(err) => match token.parse::<ThingsId>() { Ok(id) if removable && store.tag_is_carried(&id) => id, _ => return (Vec::new(), err), }, }; // a tag that did not replay completely may not be what it shows // taking one off an item stays allowed if !removable && store .tags_by_uuid .get(&tag_uuid) .is_some_and(|tag| tag.degraded) { return ( Vec::new(), format!( "Tag did not replay completely: {}", one_line(&store.resolve_tag_title(&tag_uuid)) ), ); } if seen.insert(tag_uuid.clone()) { resolved.push(tag_uuid); } }
(resolved, String::new())}
fn resolve_single_tag_id(tags: &[Tag], token: &str) -> Result<ThingsId, String> { let exact = tags .iter() .filter(|tag| tag.title.trim().to_lowercase() == token.to_lowercase()) .map(|tag| tag.uuid.clone()) .collect::<Vec<_>>(); if exact.len() == 1 { return Ok(exact[0].clone()); } if exact.len() > 1 { return Err(format!("Ambiguous tag title: {token}")); }
let prefix = tags .iter() .filter(|tag| tag.uuid.starts_with(token)) .map(|tag| tag.uuid.clone()) .collect::<Vec<_>>(); if prefix.len() == 1 { return Ok(prefix[0].clone()); } if prefix.len() > 1 { return Err(format!( "Ambiguous tag ID prefix '{token}' ({} matches).", prefix.len() )); }
Err(format!("Tag not found: {token}"))}
#[cfg(test)]mod tests { use chrono::{FixedOffset, Local, NaiveTime, TimeZone, Utc};
use super::{ day_of, day_timestamp, local_date_as_utc_midnight, note_lines, one_line, parse_day, parse_instant, printable, printable_json, printable_plain, };
#[test] fn printable_keeps_colors_and_escapes_other_controls() { assert_eq!( printable("\u{1b}[1;32m✓ Done\u{1b}[0m\tnote\n"), "\u{1b}[1;32m✓ Done\u{1b}[0m\tnote\n" ); assert_eq!( printable("Pay rent\u{1b}]52;c;aGk=\u{7}\u{1b}[2J\rX\u{7f}\u{9b}"), "Pay rent^[]52;c;aGk=^G^[[2J^MX^?\u{fffd}" ); assert_eq!( printable("Invoice \u{202e}fdp.exe\u{202c} \u{2067}x\u{2069}"), "Invoice \u{fffd}fdp.exe\u{fffd} \u{fffd}x\u{fffd}" ); }
#[test] fn output_without_colors_passes_no_escape_sequence() { assert_eq!( printable_plain("A\u{1b}[8mB\u{1b}[0m\tnote\r\nnext"), "A^[[8mB^[[0m\tnote^M\nnext" ); }
#[test] fn a_note_keeps_its_own_lines_and_escapes_the_rest() { assert_eq!( note_lines("bring photos\rand the passport\nlandlord\u{2028}IBAN\u{85}lease"), "bring photos^Mand the passport\nlandlord\u{fffd}IBAN\u{fffd}lease" ); // an ESC before a sequence that a stripper removes would join what follows assert_eq!( note_lines("plan \u{1b}\u{1b}[31m[8mconcealed"), "plan ^[^[[31m[8mconcealed" ); }
#[test] fn a_field_stays_on_its_line_without_escape_sequences() { assert_eq!( one_line("Renew\nKind: to-do, done\r\u{1b}[8m\t\u{85}\u{2028}\u{202e}"), "Renew^JKind: to-do, done^M^[[8m^I\u{fffd}\u{fffd}\u{fffd}" ); }
#[test] fn printable_json_keeps_every_string_as_it_decodes() { let title = "Rent\u{7f} due\u{85}\u{9b}2J \u{1b}]52;c;aGk=\u{7} \u{202e}fdp.exe"; let json = serde_json::to_string(&serde_json::json!({ "title": title })).unwrap(); let shown = printable_json(&json); assert!( !shown.chars().any(|c| c.is_control() || c == '\u{202e}'), "{shown}" ); let read: serde_json::Value = serde_json::from_str(&shown).unwrap(); assert_eq!(read["title"], title); }
#[test] fn today_uses_the_local_date_after_utc_midnight() { let eastern = FixedOffset::west_opt(4 * 60 * 60).unwrap(); let local_now = eastern.with_ymd_and_hms(2026, 8, 27, 21, 23, 0).unwrap();
assert_eq!( local_date_as_utc_midnight(local_now), Utc.with_ymd_and_hms(2026, 8, 27, 0, 0, 0).unwrap() ); }
#[test] fn a_day_flag_goes_on_the_wire_at_utc_midnight_in_winter_and_summer() { // the values the app writes for these days, whatever offset the process runs under today and whatever offset holds on the day itself let wire = |text: &str| parse_day(text, "--when").map(day_timestamp); assert_eq!(wire("2027-01-15"), Ok(1_799_971_200)); assert_eq!(wire("2027-01-20"), Ok(1_800_403_200)); assert_eq!(wire("2027-07-15"), Ok(1_815_609_600)); assert!(parse_day("2027-13-01", "--when").is_err()); }
#[test] fn a_day_given_for_an_instant_falls_inside_that_local_day() { let instant = parse_instant("2027-07-15", "--completed-on").expect("instant"); let local = Local .timestamp_opt(instant as i64, 0) .single() .expect("an instant"); assert_eq!(local.date_naive(), day_of(1_815_609_600).expect("day")); assert_eq!(local.time(), NaiveTime::MIN); assert_eq!( parse_instant("2027-07-15T10:20:30Z", "--completed-on"), Ok(1_815_646_830.0) ); assert!(parse_instant("July", "--completed-on").is_err()); }}