diff --git a/Cargo.toml b/Cargo.toml index efc8ce3..4831324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ time = { version = "0.3.37", features = [ "macros", "serde-human-readable", "serde-well-known", + "local-offset", ] } futures-util = "0.3.31" ress = "0.11.0" @@ -54,6 +55,7 @@ data-encoding = "2.0.0" urlencoding = "2.1.0" quick-xml = { version = "0.37.0", features = ["serialize"] } tracing = { version = "0.1.0", features = ["log"] } +localzone = "0.3.1" # CLI indicatif = "0.17.0" @@ -112,6 +114,7 @@ phf.workspace = true data-encoding.workspace = true urlencoding.workspace = true tracing.workspace = true +localzone.workspace = true quick-xml = { workspace = true, optional = true } [dev-dependencies] diff --git a/bg_snapshot.bin b/bg_snapshot.bin new file mode 100644 index 0000000..e2228dc Binary files /dev/null and b/bg_snapshot.bin differ diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 519d210..c4cd005 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -12,6 +12,7 @@ description = "CLI for RustyPipe - download videos and extract data from YouTube [features] default = ["native-tls"] +timezone = ["dep:time", "dep:time-tz"] # Reqwest TLS options native-tls = [ @@ -49,6 +50,8 @@ futures-util.workspace = true serde.workspace = true serde_json.workspace = true quick-xml.workspace = true +time = { workspace = true, optional = true } +time-tz = { version = "2.0.0", optional = true } indicatif.workspace = true anyhow.workspace = true diff --git a/cli/README.md b/cli/README.md index b4056c4..28c34a4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -135,6 +135,12 @@ Fetch a list of all the items saved in your YouTube/YouTube Music profile. `--auth` flag you can use authentication for any request. - `--lang` Change the YouTube content language - `--country` Change the YouTube content country +- `--tz` Use a specific + [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g. + Europe/Berlin, Australia/Sydney) + + **Note:** this requires building rustypipe-cli with the `timezone` feature +- `--local-tz` Use the local timezone instead of UTC - `--report` Generate a report on every request and store it in a `rustypipe_reports` folder in the current directory - `--cache-file` Change the RustyPipe cache file location (Default: diff --git a/cli/src/main.rs b/cli/src/main.rs index 7f49119..f192dd8 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -55,6 +55,13 @@ struct Cli { /// YouTube content country #[clap(long, global = true)] country: Option, + /// Use a specific timezone (e.g. Europe/Berlin, Australia/Sydney) + #[cfg(feature = "timezone")] + #[clap(long, global = true)] + tz: Option, + /// Use local timezone + #[clap(long, global = true)] + tz_local: bool, /// Use authentication #[clap(long, global = true)] auth: bool, @@ -913,6 +920,23 @@ async fn run() -> anyhow::Result<()> { if let Some(botguard_bin) = cli.botguard_bin { rp = rp.botguard_bin(botguard_bin); } + if cli.tz_local { + rp = rp.timezone_local(); + } + + #[cfg(feature = "timezone")] + if let Some(timezone) = cli.tz { + use time::OffsetDateTime; + use time_tz::{Offset, TimeZone}; + + let tz = time_tz::timezones::get_by_name(&timezone).expect("invalid timezone"); + let offset = tz + .get_offset_utc(&OffsetDateTime::now_utc()) + .to_utc() + .whole_minutes(); + rp = rp.timezone(tz.name(), offset); + } + if cli.no_botguard { rp = rp.no_botguard(); } diff --git a/src/client/channel.rs b/src/client/channel.rs index 533e0a3..8dcb827 100644 --- a/src/client/channel.rs +++ b/src/client/channel.rs @@ -280,7 +280,7 @@ impl MapResponse>> for response::Channel { impl MapResponse for response::ChannelAbout { fn map_response(self, ctx: &MapRespCtx<'_>) -> Result, ExtractionError> { - // Channel info is always fetched in English. There is no localized data there + // Channel info is always fetched in English. There is no localized data // and it allows parsing the country name. let lang = Language::En; @@ -335,7 +335,7 @@ impl MapResponse for response::ChannelAbout { .video_count_text .and_then(|txt| util::parse_numeric_or_warn(&txt, &mut warnings)), create_date: about.joined_date_text.and_then(|txt| { - timeago::parse_textual_date_or_warn(lang, &txt, &mut warnings) + timeago::parse_textual_date_or_warn(lang, ctx.utc_offset, &txt, &mut warnings) .map(OffsetDateTime::date) }), view_count: about diff --git a/src/client/history.rs b/src/client/history.rs index 6852b60..427715c 100644 --- a/src/client/history.rs +++ b/src/client/history.rs @@ -181,6 +181,7 @@ impl MapResponse>> for response::History { mapper.map_response(contents); mapper.conv_history_items( header.map(|h| h.item_section_header_renderer.title), + ctx.utc_offset, &mut map_res, ); } diff --git a/src/client/mod.rs b/src/client/mod.rs index 485f2a2..61e358e 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -35,7 +35,7 @@ use regex::Regex; use reqwest::{header, Client, ClientBuilder, Request, RequestBuilder, Response, StatusCode}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha1::{Digest, Sha1}; -use time::OffsetDateTime; +use time::{OffsetDateTime, UtcOffset}; use tokio::sync::RwLock as AsyncRwLock; use crate::error::AuthError; @@ -83,7 +83,6 @@ pub enum ClientType { /// Client used by the iOS app /// /// - no obfuscated stream URLs - /// - does not include opus audio streams Ios, } @@ -387,6 +386,8 @@ struct RustyPipeRef { struct RustyPipeOpts { lang: Language, country: Country, + timezone: Option, + utc_offset_minutes: i16, report: bool, strict: bool, auth: Option, @@ -526,6 +527,8 @@ impl Default for RustyPipeOpts { Self { lang: Language::En, country: Country::Us, + timezone: None, + utc_offset_minutes: 0, report: false, strict: false, auth: None, @@ -891,6 +894,29 @@ impl RustyPipeBuilder { self } + /// Set the timezone and its associated UTC offset in minutes used + /// when accessing the YouTube API. + /// + /// **Default value**: `0` (UTC) + /// + /// **Info**: you can set this option for individual queries, too + #[must_use] + pub fn timezone>(mut self, timezone: S, utc_offset_minutes: i16) -> Self { + self.default_opts.timezone = Some(timezone.into()); + self.default_opts.utc_offset_minutes = utc_offset_minutes; + self + } + + /// Access the YouTube API using the local system timezone + /// + /// If the local timezone could not be determined, an error is logged and RustyPipe falls + /// back to UTC. + #[must_use] + pub fn timezone_local(self) -> Self { + let (timezone, utc_offset_minutes) = local_tz_offset(); + self.timezone(timezone, utc_offset_minutes) + } + /// Generate a report on every operation. /// /// This should only be used for debugging. @@ -1669,6 +1695,22 @@ impl RustyPipeQuery { self } + /// Set the timezone and its associated UTC offset in minutes used + /// when accessing the YouTube API. + #[must_use] + pub fn timezone>(mut self, timezone: S, utc_offset_minutes: i16) -> Self { + self.opts.timezone = Some(timezone.into()); + self.opts.utc_offset_minutes = utc_offset_minutes; + self + } + + /// Access the YouTube API using the local system timezone + #[must_use] + pub fn timezone_local(self) -> Self { + let (timezone, utc_offset_minutes) = local_tz_offset(); + self.timezone(timezone, utc_offset_minutes) + } + /// Generate a report on every operation. /// /// This should only be used for debugging. @@ -1823,6 +1865,8 @@ impl RustyPipeQuery { } else { (Language::En, Country::Us) }; + let utc_offset_minutes = self.opts.utc_offset_minutes; + let time_zone = self.opts.timezone.as_deref().unwrap_or("UTC"); match ctype { ClientType::Desktop => YTContext { @@ -1834,6 +1878,8 @@ impl RustyPipeQuery { visitor_data, hl, gl, + time_zone, + utc_offset_minutes, ..Default::default() }, request: Some(RequestYT::default()), @@ -1849,6 +1895,8 @@ impl RustyPipeQuery { visitor_data, hl, gl, + time_zone, + utc_offset_minutes, ..Default::default() }, request: Some(RequestYT::default()), @@ -1864,6 +1912,8 @@ impl RustyPipeQuery { visitor_data, hl, gl, + time_zone, + utc_offset_minutes, ..Default::default() }, request: Some(RequestYT::default()), @@ -1880,6 +1930,8 @@ impl RustyPipeQuery { visitor_data, hl, gl, + time_zone, + utc_offset_minutes, ..Default::default() }, request: Some(RequestYT::default()), @@ -1899,6 +1951,8 @@ impl RustyPipeQuery { visitor_data, hl, gl, + time_zone, + utc_offset_minutes, ..Default::default() }, request: None, @@ -1916,6 +1970,8 @@ impl RustyPipeQuery { visitor_data, hl, gl, + time_zone, + utc_offset_minutes, ..Default::default() }, request: None, @@ -2213,6 +2269,8 @@ impl RustyPipeQuery { let ctx = MapRespCtx { id, lang: self.opts.lang, + utc_offset: UtcOffset::from_whole_seconds(i32::from(self.opts.utc_offset_minutes) * 60) + .map_err(|_| Error::Other("utc_offset overflow".into()))?, deobf: ctx_src.deobf, visitor_data: Some(&visitor_data), client_type: ctype, @@ -2499,6 +2557,7 @@ impl AsRef for RustyPipeQuery { struct MapRespCtx<'a> { id: &'a str, lang: Language, + utc_offset: UtcOffset, deobf: Option<&'a DeobfData>, visitor_data: Option<&'a str>, client_type: ClientType, @@ -2526,6 +2585,7 @@ impl<'a> MapRespCtx<'a> { Self { id, lang: Language::En, + utc_offset: UtcOffset::UTC, deobf: None, visitor_data: None, client_type: ClientType::Desktop, @@ -2564,6 +2624,19 @@ fn validate_country(country: Country) -> Country { } } +fn local_tz_offset() -> (String, i16) { + match ( + localzone::get_local_zone().ok_or(Error::Other("could not get local timezone".into())), + UtcOffset::current_local_offset().map_err(|_| Error::Other("indeterminate offset".into())), + ) { + (Ok(timezone), Ok(offset)) => (timezone, offset.whole_minutes()), + (Err(e), _) | (_, Err(e)) => { + tracing::error!("{e}"); + ("UTC".to_owned(), 0) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/client/music_history.rs b/src/client/music_history.rs index 0ebfcf0..50c5844 100644 --- a/src/client/music_history.rs +++ b/src/client/music_history.rs @@ -160,7 +160,7 @@ impl MapResponse>> for response::MusicHistory { }; let mut mapper = MusicListMapper::new(ctx.lang); mapper.map_response(shelf.contents); - mapper.conv_history_items(shelf.title, &mut map_res); + mapper.conv_history_items(shelf.title, ctx.utc_offset, &mut map_res); } let ctoken = contents diff --git a/src/client/pagination.rs b/src/client/pagination.rs index 3fad657..1440ebe 100644 --- a/src/client/pagination.rs +++ b/src/client/pagination.rs @@ -241,6 +241,7 @@ impl MapResponse>> for response::Continuation { mapper.map_response(contents); mapper.conv_history_items( header.map(|h| h.item_section_header_renderer.title), + ctx.utc_offset, &mut map_res, ); } @@ -280,7 +281,7 @@ impl MapResponse>> for response::MusicContinuat let mut map_shelf = |shelf: response::music_item::MusicShelf| { let mut mapper = MusicListMapper::new(ctx.lang); mapper.map_response(shelf.contents); - mapper.conv_history_items(shelf.title, &mut map_res); + mapper.conv_history_items(shelf.title, ctx.utc_offset, &mut map_res); continuations.extend(shelf.continuations); }; diff --git a/src/client/player.rs b/src/client/player.rs index d85394c..f5e0c53 100644 --- a/src/client/player.rs +++ b/src/client/player.rs @@ -937,6 +937,7 @@ mod tests { use path_macro::path; use rstest::rstest; + use time::UtcOffset; use super::*; use crate::{deobfuscate::DeobfData, param::Language, util::tests::TESTFILES}; @@ -968,6 +969,7 @@ mod tests { .map_response(&MapRespCtx { id: "pPvd8UxmSbQ", lang: Language::En, + utc_offset: UtcOffset::UTC, deobf: Some(&DEOBF_DATA), visitor_data: None, client_type, diff --git a/src/client/playlist.rs b/src/client/playlist.rs index c2872e6..79e2329 100644 --- a/src/client/playlist.rs +++ b/src/client/playlist.rs @@ -225,8 +225,13 @@ impl MapResponse for response::Playlist { .as_deref() .or(last_update_txt2.as_deref()) .and_then(|txt| { - timeago::parse_textual_date_or_warn(ctx.lang, txt, &mut mapper.warnings) - .map(OffsetDateTime::date) + timeago::parse_textual_date_or_warn( + ctx.lang, + ctx.utc_offset, + txt, + &mut mapper.warnings, + ) + .map(OffsetDateTime::date) }); Ok(MapResult { diff --git a/src/client/response/music_item.rs b/src/client/response/music_item.rs index f5e0fac..d2bf983 100644 --- a/src/client/response/music_item.rs +++ b/src/client/response/music_item.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde_with::{rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError}; +use time::UtcOffset; use crate::{ model::{ @@ -1272,6 +1273,7 @@ impl MusicListMapper { pub fn conv_history_items( self, date_txt: Option, + utc_offset: UtcOffset, res: &mut MapResult>>, ) { res.warnings.extend(self.warnings); @@ -1282,7 +1284,12 @@ impl MusicListMapper { .map(|item| HistoryItem { item, playback_date: date_txt.as_deref().and_then(|s| { - timeago::parse_textual_date_to_d(self.lang, s, &mut res.warnings) + timeago::parse_textual_date_to_d( + self.lang, + utc_offset, + s, + &mut res.warnings, + ) }), playback_date_txt: date_txt.clone(), }), diff --git a/src/client/response/video_item.rs b/src/client/response/video_item.rs index 1541728..f855f3f 100644 --- a/src/client/response/video_item.rs +++ b/src/client/response/video_item.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use serde_with::{ rust::deserialize_ignore_any, serde_as, DefaultOnError, DisplayFromStr, VecSkipError, }; -use time::OffsetDateTime; +use time::{OffsetDateTime, UtcOffset}; use super::{ ChannelBadge, ContentImage, ContinuationEndpoint, PhMetadataView, SimpleHeaderRenderer, @@ -786,7 +786,7 @@ impl YouTubeListMapper { thumbnail: tn.image.into(), channel, publish_date: publish_date_txt.as_deref().and_then(|t| { - timeago::parse_textual_date_or_warn(self.lang, t, &mut self.warnings) + timeago::parse_timeago_dt_or_warn(self.lang, t, &mut self.warnings) }), publish_date_txt, view_count, @@ -907,17 +907,16 @@ impl YouTubeListMapper { pub(crate) fn conv_history_items( self, date_txt: Option, + utc_offset: UtcOffset, res: &mut MapResult>>, ) { res.warnings.extend(self.warnings); - res.c.extend(self.items.into_iter().map(|item| { - HistoryItem { - item, - playback_date: date_txt.as_deref().and_then(|s| { - timeago::parse_textual_date_to_d(self.lang, s, &mut res.warnings) - }), - playback_date_txt: date_txt.clone(), - } + res.c.extend(self.items.into_iter().map(|item| HistoryItem { + item, + playback_date: date_txt.as_deref().and_then(|s| { + timeago::parse_textual_date_to_d(self.lang, utc_offset, s, &mut res.warnings) + }), + playback_date_txt: date_txt.clone(), })); } } diff --git a/src/client/video_details.rs b/src/client/video_details.rs index 9cc1ffc..f53201b 100644 --- a/src/client/video_details.rs +++ b/src/client/video_details.rs @@ -180,7 +180,12 @@ impl MapResponse for response::VideoDetails { // so we ignore parse errors here for now like_text.and_then(|txt| util::parse_numeric(&txt).ok()), date_text.as_deref().and_then(|txt| { - timeago::parse_textual_date_or_warn(ctx.lang, txt, &mut warnings) + timeago::parse_textual_date_or_warn( + ctx.lang, + ctx.utc_offset, + txt, + &mut warnings, + ) }), date_text, view_count diff --git a/src/util/date.rs b/src/util/date.rs index dde5d88..b21fed3 100644 --- a/src/util/date.rs +++ b/src/util/date.rs @@ -25,7 +25,8 @@ pub fn shift_years(date: Date, years: i32) -> Date { shift_months(date, years * 12) } -pub fn shift_weeks_mo(date: Date, weeks: i32) -> Date { +/// Shift a date to the monday of its week, plus/minus the given amount of weeks +pub fn shift_weeks_monday(date: Date, weeks: i32) -> Date { let d = date + Duration::weeks(weeks.into()); Date::from_iso_week_date(d.year(), d.iso_week(), time::Weekday::Monday).unwrap() } @@ -40,3 +41,18 @@ pub fn now_sec() -> OffsetDateTime { .replace_nanosecond(0) .unwrap() } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use time::{macros::date, Date}; + + #[rstest] + #[case::this_week(date!(2025-01-17), 0, date!(2025-01-13))] + #[case::last_week(date!(2025-01-17), -1, date!(2025-01-06))] + #[case::last_month(date!(2025-01-17), -4, date!(2024-12-16))] + fn shift_weeks_monday(#[case] date: Date, #[case] weeks: i32, #[case] expect: Date) { + let res = super::shift_weeks_monday(date, weeks); + assert_eq!(res, expect); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs index 9a49571..0f4499f 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -5,7 +5,7 @@ mod visitor_data; pub mod dictionary; pub mod timeago; -pub use date::{now_sec, shift_months, shift_weeks_mo, shift_years}; +pub use date::{now_sec, shift_months, shift_weeks_monday, shift_years}; pub use protobuf::{string_from_pb, ProtoBuilder}; pub use visitor_data::VisitorDataCache; diff --git a/src/util/timeago.rs b/src/util/timeago.rs index 274c653..011b3ed 100644 --- a/src/util/timeago.rs +++ b/src/util/timeago.rs @@ -13,7 +13,7 @@ use std::ops::Mul; use serde::{Deserialize, Serialize}; -use time::{Date, Duration, Month, OffsetDateTime}; +use time::{Date, Duration, Month, OffsetDateTime, UtcOffset}; use crate::{ param::Language, @@ -97,6 +97,26 @@ impl TimeAgo { fn secs(self) -> u32 { u32::from(self.n) * self.unit.secs() } + + fn into_datetime(self, utc_offset: UtcOffset) -> OffsetDateTime { + let ts = util::now_sec().to_offset(utc_offset); + match self.unit { + TimeUnit::Month => ts.replace_date(util::shift_months(ts.date(), -i32::from(self.n))), + TimeUnit::Year => ts.replace_date(util::shift_years(ts.date(), -i32::from(self.n))), + TimeUnit::LastWeek => { + ts.replace_date(util::shift_weeks_monday(ts.date(), -i32::from(self.n))) + } + TimeUnit::LastWeekday => ts.replace_date( + Date::from_iso_week_date( + ts.year(), + ts.iso_week(), + time::Weekday::Monday.nth_next(self.n), + ) + .unwrap(), + ), + _ => ts - Duration::from(self), + } + } } impl Mul for TimeAgo { @@ -116,33 +136,11 @@ impl From for Duration { } } -impl From for OffsetDateTime { - fn from(ta: TimeAgo) -> Self { - let ts = util::now_sec(); - match ta.unit { - TimeUnit::Month => ts.replace_date(util::shift_months(ts.date(), -i32::from(ta.n))), - TimeUnit::Year => ts.replace_date(util::shift_years(ts.date(), -i32::from(ta.n))), - TimeUnit::LastWeek => { - ts.replace_date(util::shift_weeks_mo(ts.date(), -i32::from(ta.n))) - } - TimeUnit::LastWeekday => ts.replace_date( - Date::from_iso_week_date( - ts.year(), - ts.iso_week(), - time::Weekday::Monday.nth_next(ta.n), - ) - .unwrap(), - ), - _ => ts - Duration::from(ta), - } - } -} - -impl From for OffsetDateTime { - fn from(date: ParsedDate) -> Self { - match date { - ParsedDate::Absolute(date) => date.with_hms(0, 0, 0).unwrap().assume_utc(), - ParsedDate::Relative(timeago) => timeago.into(), +impl ParsedDate { + fn into_datetime(self, utc_offset: UtcOffset) -> OffsetDateTime { + match self { + ParsedDate::Absolute(date) => date.with_hms(0, 0, 0).unwrap().assume_offset(utc_offset), + ParsedDate::Relative(timeago) => timeago.into_datetime(utc_offset), } } } @@ -247,7 +245,7 @@ pub fn parse_timeago(lang: Language, textual_date: &str) -> Option { /// /// Returns [`None`] if the date could not be parsed. pub fn parse_timeago_dt(lang: Language, textual_date: &str) -> Option { - parse_timeago(lang, textual_date).map(OffsetDateTime::from) + parse_timeago(lang, textual_date).map(|t| t.into_datetime(UtcOffset::UTC)) } pub fn parse_timeago_dt_or_warn( @@ -265,7 +263,11 @@ pub fn parse_timeago_dt_or_warn( /// Parse a textual date (e.g. "29 minutes ago" or "Jul 2, 2014") into a ParsedDate object. /// /// Returns [`None`] if the date could not be parsed. -pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option { +pub fn parse_textual_date( + lang: Language, + utc_offset: UtcOffset, + textual_date: &str, +) -> Option { let entry = dictionary::entry(lang); let by_char = util::lang_by_char(lang); let filtered_str = filter_datestr(textual_date); @@ -317,8 +319,9 @@ pub fn parse_textual_date(lang: Language, textual_date: &str) -> Option Option Option { - parse_textual_date(lang, textual_date).map(OffsetDateTime::from) +pub fn parse_textual_date_to_dt( + lang: Language, + utc_offset: UtcOffset, + textual_date: &str, +) -> Option { + parse_textual_date(lang, utc_offset, textual_date).map(|t| t.into_datetime(utc_offset)) } /// Parse a textual date (e.g. "29 minutes ago" "Jul 2, 2014") into a Date object. @@ -342,18 +349,21 @@ pub fn parse_textual_date_to_dt(lang: Language, textual_date: &str) -> Option, ) -> Option { - parse_textual_date_or_warn(lang, textual_date, warnings).map(OffsetDateTime::date) + parse_textual_date_or_warn(lang, utc_offset, textual_date, warnings) + .map(|d| d.to_offset(utc_offset).date()) } pub fn parse_textual_date_or_warn( lang: Language, + utc_offset: UtcOffset, textual_date: &str, warnings: &mut Vec, ) -> Option { - let res = parse_textual_date_to_dt(lang, textual_date); + let res = parse_textual_date_to_dt(lang, utc_offset, textual_date); if res.is_none() { warnings.push(format!("could not parse textual date `{textual_date}`")); } @@ -864,7 +874,7 @@ mod tests { for (t, entry) in entries { entry.cases.iter().for_each(|(txt, n)| { let timeago = parse_timeago(*lang, txt); - let textual_date = parse_textual_date(*lang, txt); + let textual_date = parse_textual_date(*lang, UtcOffset::UTC, txt); assert_eq!( timeago, Some(TimeAgo { n: *n, unit: *t }), @@ -906,7 +916,7 @@ mod tests { #[case] textual_date: &str, #[case] expect: Option, ) { - let parsed_date = parse_textual_date(lang, textual_date); + let parsed_date = parse_textual_date(lang, UtcOffset::UTC, textual_date); assert_eq!(parsed_date, expect); } @@ -917,7 +927,7 @@ mod tests { #[case] textual_date: &str, #[case] expect: Date, ) { - let parsed_date = parse_textual_date(lang, textual_date); + let parsed_date = parse_textual_date(lang, UtcOffset::UTC, textual_date); let expected_date = expect .replace_year(OffsetDateTime::now_utc().year()) .unwrap(); @@ -933,7 +943,7 @@ mod tests { for (lang, samples) in &date_samples { assert_eq!( - parse_textual_date(*lang, samples.get("Today").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Today").unwrap()), Some(ParsedDate::Relative(TimeAgo { n: 0, unit: TimeUnit::Day @@ -941,7 +951,7 @@ mod tests { "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Yesterday").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Yesterday").unwrap()), Some(ParsedDate::Relative(TimeAgo { n: 1, unit: TimeUnit::Day @@ -949,7 +959,7 @@ mod tests { "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Ago").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Ago").unwrap()), Some(ParsedDate::Relative(TimeAgo { n: 5, unit: TimeUnit::Day @@ -957,62 +967,62 @@ mod tests { "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Jan").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Jan").unwrap()), Some(ParsedDate::Absolute(date!(2020 - 1 - 3))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Feb").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Feb").unwrap()), Some(ParsedDate::Absolute(date!(2016 - 2 - 7))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Mar").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Mar").unwrap()), Some(ParsedDate::Absolute(date!(2015 - 3 - 9))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Apr").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Apr").unwrap()), Some(ParsedDate::Absolute(date!(2017 - 4 - 2))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("May").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("May").unwrap()), Some(ParsedDate::Absolute(date!(2014 - 5 - 22))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Jun").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Jun").unwrap()), Some(ParsedDate::Absolute(date!(2014 - 6 - 28))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Jul").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Jul").unwrap()), Some(ParsedDate::Absolute(date!(2014 - 7 - 2))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Aug").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Aug").unwrap()), Some(ParsedDate::Absolute(date!(2015 - 8 - 23))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Sep").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Sep").unwrap()), Some(ParsedDate::Absolute(date!(2018 - 9 - 16))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Oct").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Oct").unwrap()), Some(ParsedDate::Absolute(date!(2014 - 10 - 31))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Nov").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Nov").unwrap()), Some(ParsedDate::Absolute(date!(2016 - 11 - 3))), "lang: {lang}" ); assert_eq!( - parse_textual_date(*lang, samples.get("Dec").unwrap()), + parse_textual_date(*lang, UtcOffset::UTC, samples.get("Dec").unwrap()), Some(ParsedDate::Absolute(date!(2021 - 12 - 24))), "lang: {lang}" ); @@ -1058,7 +1068,7 @@ mod tests { } }; assert_eq!( - parse_textual_date(lang, &v), + parse_textual_date(lang, UtcOffset::UTC, &v), Some(expected), "lang={lang}; {k}" ); @@ -1101,11 +1111,13 @@ mod tests { #[test] fn t_to_datetime() { // Absolute date - let date = parse_textual_date_to_dt(Language::En, "Last updated on Jan 3, 2020").unwrap(); + let date = + parse_textual_date_to_dt(Language::En, UtcOffset::UTC, "Last updated on Jan 3, 2020") + .unwrap(); assert_eq!(date, datetime!(2020-1-3 0:00 +0)); // Relative date - let date = parse_textual_date_to_dt(Language::En, "1 year ago").unwrap(); + let date = parse_textual_date_to_dt(Language::En, UtcOffset::UTC, "1 year ago").unwrap(); let now = OffsetDateTime::now_utc(); assert_eq!(date.year(), now.year() - 1); }