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#" "#,
+ r#" "#,
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}
"#,
+ 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}
"#,
+ 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;