From c1d982b67a92c692c4a7310ca810a7eaa1bbcb6e Mon Sep 17 00:00:00 2001 From: mat Date: Sun, 14 Jan 2024 19:07:53 -0600 Subject: [PATCH] add timezone engine --- Cargo.lock | 58 +++++++++++- Cargo.toml | 1 + src/engines/answer.rs | 1 + src/engines/answer/calc.rs | 4 +- src/engines/answer/timezone.rs | 157 +++++++++++++++++++++++++++++++++ src/engines/mod.rs | 4 + src/web/assets/style.css | 14 +-- 7 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 src/engines/answer/timezone.rs diff --git a/Cargo.lock b/Cargo.lock index d629ea2..f679362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,30 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "chrono-tz" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d7b79e99bfaa0d47da0687c43aa3b7381938a62ad3a6498599039321f660b7" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf 0.11.2", + "uncased", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf 0.11.2", + "phf_codegen 0.11.2", + "uncased", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -881,7 +905,7 @@ checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" dependencies = [ "log", "phf 0.10.1", - "phf_codegen", + "phf_codegen 0.10.0", "string_cache", "string_cache_codegen", "tendril", @@ -909,6 +933,7 @@ dependencies = [ "base64", "bytes", "chrono", + "chrono-tz", "eyre", "fend-core", "futures", @@ -1004,6 +1029,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1039,6 +1073,16 @@ dependencies = [ "phf_shared 0.10.0", ] +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + [[package]] name = "phf_generator" version = "0.10.0" @@ -1088,6 +1132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" dependencies = [ "siphasher", + "uncased", ] [[package]] @@ -1370,7 +1415,7 @@ dependencies = [ "log", "new_debug_unreachable", "phf 0.10.1", - "phf_codegen", + "phf_codegen 0.10.0", "precomputed-hash", "servo_arc", "smallvec", @@ -1696,6 +1741,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "uncased" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bc53168a4be7402ab86c3aad243a84dd7381d09be0eddc81280c1da95ca68" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.14" diff --git a/Cargo.toml b/Cargo.toml index 385f161..a761658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ axum = { version = "0.7.2", default-features = false, features = [ base64 = "0.21.5" bytes = "1.5.0" chrono = "0.4.31" +chrono-tz = { version = "0.8.5", features = ["case-insensitive"] } eyre = "0.6.11" fend-core = "1.3.3" futures = "0.3.29" diff --git a/src/engines/answer.rs b/src/engines/answer.rs index 6496221..501e092 100644 --- a/src/engines/answer.rs +++ b/src/engines/answer.rs @@ -1,6 +1,7 @@ pub mod calc; pub mod dictionary; pub mod ip; +pub mod timezone; pub mod useragent; pub mod wikipedia; diff --git a/src/engines/answer/calc.rs b/src/engines/answer/calc.rs index f72dc39..1b85d82 100644 --- a/src/engines/answer/calc.rs +++ b/src/engines/answer/calc.rs @@ -14,7 +14,7 @@ pub fn request(query: &str) -> EngineResponse { }; EngineResponse::answer_html(format!( - r#"

{query} =

+ r#"

{query} =

{result_html}

"#, query = html_escape::encode_text(&query), )) @@ -87,7 +87,7 @@ fn evaluate(query: &str, html: bool) -> Option { let hex = spans[0].text.trim_start_matches("0x"); if let Ok(num) = u64::from_str_radix(hex, 16) { result_html.push_str(&format!( - r#" = {num}"#, + r#" = {num}"#, num = num )); } diff --git a/src/engines/answer/timezone.rs b/src/engines/answer/timezone.rs new file mode 100644 index 0000000..198475f --- /dev/null +++ b/src/engines/answer/timezone.rs @@ -0,0 +1,157 @@ +use chrono::{DateTime, TimeZone}; +use chrono_tz::{OffsetComponents, OffsetName, Tz}; + +use crate::engines::EngineResponse; + +use super::regex; + +pub fn request(query: &str) -> EngineResponse { + match evaluate(query) { + None => EngineResponse::new(), + Some(TimeResponse::Current { time, timezone }) => EngineResponse::answer_html(format!( + r#"

Current time in {timezone}

+

{time} ({date})

"#, + time = html_escape::encode_text(&time.format("%-I:%M %P").to_string()), + date = html_escape::encode_text(&time.format("%B %-d").to_string()), + timezone = html_escape::encode_text(&timezone_to_string(&timezone)), + )), + Some(TimeResponse::Conversion { + source_timezone, + target_timezone, + source_time, + target_time, + source_offset, + target_offset, + }) => EngineResponse::answer_html(format!( + r#"

{source_time} {source_timezone} to {target_timezone}

+

{target_time} {target_timezone} ({delta})

"#, + source_time = html_escape::encode_text(&source_time.format("%-I:%M %P").to_string()), + target_time = html_escape::encode_text(&target_time.format("%-I:%M %P").to_string()), + source_timezone = html_escape::encode_text(&timezone_to_string(&source_timezone)), + target_timezone = html_escape::encode_text(&timezone_to_string(&target_timezone)), + delta = html_escape::encode_text(&{ + let delta_minutes = (target_offset - source_offset).num_minutes(); + if delta_minutes % 60 == 0 { + format!("{:+}", delta_minutes / 60) + } else { + format!("{:+}:{}", delta_minutes / 60, delta_minutes % 60) + } + }) + )), + } +} + +enum TimeResponse { + Current { + time: DateTime, + timezone: Tz, + }, + Conversion { + source_timezone: Tz, + target_timezone: Tz, + source_time: DateTime, + target_time: DateTime, + source_offset: chrono::Duration, + target_offset: chrono::Duration, + }, +} + +fn evaluate(query: &str) -> Option { + // "4pm utc to cst" + let re = regex!(r"(\d{1,2})(?:(\d{1,2}))?\s*(am|pm|) ([\w/+\-]+) (to|as|in) ([\w/+\-]+)"); + if let Some(captures) = re.captures(query) { + if let Some(hour) = captures.get(1).map(|m| m.as_str().parse::().unwrap()) { + let minute = match captures.get(2) { + Some(m) => m.as_str().parse::().ok()?, + None => 0, + }; + let ampm = captures.get(3).unwrap().as_str(); + let timezone1_name = captures.get(4).unwrap().as_str(); + let timezone2_name = captures.get(6).unwrap().as_str(); + + let source_timezone = parse_timezone(timezone1_name)?; + let target_timezone = parse_timezone(timezone2_name)?; + + let current_date = chrono::Utc::now().date_naive(); + + let source_offset = source_timezone.offset_from_utc_date(¤t_date); + let target_offset = target_timezone.offset_from_utc_date(¤t_date); + + println!( + "source_offset: {:?} {:?}", + source_offset, + source_offset.tz_id() + ); + println!("target_offset: {:?}", target_offset); + + let source_time_naive = current_date.and_hms_opt( + if ampm == "pm" && hour != 12 { + hour + 12 + } else if ampm == "am" && hour == 12 { + 0 + } else { + hour + }, + minute, + 0, + )?; + let source_time_utc = chrono::Utc + .from_local_datetime(&source_time_naive) + .latest()?; + + let source_time = source_time_utc.with_timezone(&source_timezone); + let target_time = source_time_utc.with_timezone(&target_timezone); + + return Some(TimeResponse::Conversion { + source_timezone, + target_timezone, + source_time, + target_time, + // the offsets are wrong for some reason so we have to negate them + source_offset: -source_offset.base_utc_offset(), + target_offset: -target_offset.base_utc_offset(), + }); + } + } + + // "utc time" + let re = regex!(r"([\w/+\-]+)(?: current)? time"); + // "time in utc" + let re2 = regex!(r"time (?:in|as) ([\w/+\-]+)"); + if let Some(timezone_name) = re + .captures(query) + .and_then(|m| m.get(1)) + .or_else(|| re2.captures(query).and_then(|m| m.get(1))) + { + if let Some(timezone) = parse_timezone(timezone_name.as_str()) { + let time = chrono::Utc::now().with_timezone(&timezone); + return Some(TimeResponse::Current { time, timezone }); + } + } + + None +} + +fn parse_timezone(timezone_name: &str) -> Option { + match timezone_name.to_lowercase().as_str() { + "cst" => Some(Tz::CST6CDT), + "cdt" => Some(Tz::CST6CDT), + _ => Tz::from_str_insensitive(timezone_name) + .ok() + .or_else(|| Tz::from_str_insensitive(&format!("etc/{timezone_name}")).ok()), + } +} + +fn timezone_to_string(tz: &Tz) -> String { + match tz { + Tz::CST6CDT => "CST".to_string(), + _ => { + let tz_string = tz.name(); + if let Some(tz_string) = tz_string.strip_prefix("Etc/") { + tz_string.to_string() + } else { + tz_string.to_string() + } + } + } +} diff --git a/src/engines/mod.rs b/src/engines/mod.rs index 74ec5c1..6487b7b 100644 --- a/src/engines/mod.rs +++ b/src/engines/mod.rs @@ -34,6 +34,7 @@ engines! { Calc = "calc", Wikipedia = "wikipedia", Dictionary = "dictionary", + Timezone = "timezone", // post-search StackExchange = "stackexchange", GitHub = "github", @@ -49,15 +50,18 @@ engine_weights! { } engine_requests! { + // search Google => search::google::request, parse_response, Bing => search::bing::request, parse_response, Brave => search::brave::request, parse_response, Marginalia => search::marginalia::request, parse_response, + // answer Useragent => answer::useragent::request, None, Ip => answer::ip::request, None, Calc => answer::calc::request, None, Wikipedia => answer::wikipedia::request, parse_response, Dictionary => answer::dictionary::request, parse_response, + Timezone => answer::timezone::request, None, } engine_autocomplete_requests! { diff --git a/src/web/assets/style.css b/src/web/assets/style.css index a35a996..ad32d4c 100644 --- a/src/web/assets/style.css +++ b/src/web/assets/style.css @@ -185,11 +185,17 @@ h1 { font-size: 1.2rem; } -/* styles for specific answers */ -.answer-calc-query { +/* styles that are somewhat answer-specific but get reused across other styles sometimes */ +.answer-query { margin: 0; opacity: 0.5; } +.answer-comment { + color: #acb6bf8c; + font-weight: normal; +} + +/* styles for specific answers */ .answer-calc-constant { color: #d2a6ff; white-space: pre-wrap; @@ -200,10 +206,6 @@ h1 { .answer-calc-special { color: #e6b673; } -.answer-calc-comment { - color: #acb6bf8c; - font-weight: normal; -} .answer-dictionary-word { margin-top: 0;