add timezone engine

This commit is contained in:
mat 2024-01-14 19:07:53 -06:00
parent b9ace5a34f
commit c1d982b67a
7 changed files with 229 additions and 10 deletions

58
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -1,6 +1,7 @@
pub mod calc;
pub mod dictionary;
pub mod ip;
pub mod timezone;
pub mod useragent;
pub mod wikipedia;

View File

@ -14,7 +14,7 @@ pub fn request(query: &str) -> EngineResponse {
};
EngineResponse::answer_html(format!(
r#"<p class="answer-calc-query">{query} =</p>
r#"<p class="answer-query">{query} =</p>
<h3><b>{result_html}</b></h3>"#,
query = html_escape::encode_text(&query),
))
@ -87,7 +87,7 @@ fn evaluate(query: &str, html: bool) -> Option<String> {
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#" <span class="answer-calc-comment">= {num}</span>"#,
r#" <span class="answer-comment">= {num}</span>"#,
num = num
));
}

View File

@ -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#"<p class="answer-query">Current time in {timezone}</p>
<h3><b>{time}</b> <span class="answer-comment">({date})</span></h3>"#,
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#"<p class="answer-query">{source_time} {source_timezone} to {target_timezone}</p>
<h3><b>{target_time}</b> <span class="answer-comment">{target_timezone} ({delta})</span></h3>"#,
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<Tz>,
timezone: Tz,
},
Conversion {
source_timezone: Tz,
target_timezone: Tz,
source_time: DateTime<Tz>,
target_time: DateTime<Tz>,
source_offset: chrono::Duration,
target_offset: chrono::Duration,
},
}
fn evaluate(query: &str) -> Option<TimeResponse> {
// "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::<u32>().unwrap()) {
let minute = match captures.get(2) {
Some(m) => m.as_str().parse::<u32>().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(&current_date);
let target_offset = target_timezone.offset_from_utc_date(&current_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<Tz> {
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()
}
}
}
}

View File

@ -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! {

View File

@ -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;