/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use chrono::Local; use crate::{db::DEFAULT_SUGGESTION_SCORE, geoname::Geoname}; /// The template parameter for a timestamp in a "raw" sponsored suggestion URL. const TIMESTAMP_TEMPLATE: &str = "%YYYYMMDDHH%"; /// The length, in bytes, of a timestamp in a "cooked" sponsored suggestion URL. /// /// Cooked timestamps don't include the leading or trailing `%`, so this is /// 2 bytes shorter than [`TIMESTAMP_TEMPLATE`]. const TIMESTAMP_LENGTH: usize = 10; /// Subject type for Yelp suggestion. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, uniffi::Enum)] #[repr(u8)] pub enum YelpSubjectType { // Service such as sushi, ramen, yoga etc. Service = 0, // Specific business such as the shop name. Business = 1, } /// A suggestion from the database to show in the address bar. #[derive(Clone, Debug, PartialEq, uniffi::Enum)] pub enum Suggestion { Amp { title: String, url: String, raw_url: String, icon: Option>, icon_mimetype: Option, full_keyword: String, block_id: i64, advertiser: String, iab_category: String, categories: Vec, impression_url: String, click_url: String, raw_click_url: String, score: f64, fts_match_info: Option, }, Wikipedia { title: String, url: String, icon: Option>, icon_mimetype: Option, full_keyword: String, }, Amo { title: String, url: String, icon_url: String, description: String, rating: Option, number_of_ratings: i64, guid: String, score: f64, }, Yelp { url: String, title: String, icon: Option>, icon_mimetype: Option, score: f64, has_location_sign: bool, subject_exact_match: bool, subject_type: YelpSubjectType, location_param: String, }, Mdn { title: String, url: String, description: String, score: f64, }, Weather { city: Option, score: f64, }, Fakespot { fakespot_grade: String, product_id: String, rating: f64, title: String, total_reviews: i64, url: String, icon: Option>, icon_mimetype: Option, score: f64, // Details about the FTS match. For performance reasons, this is only calculated for the // result with the highest score. We assume that only one that will be shown to the user // and therefore the only one we'll collect metrics for. match_info: Option, }, Dynamic { suggestion_type: String, data: Option, /// This value is optionally defined in the suggestion's remote settings /// data and is an opaque token used for dismissing the suggestion in /// lieu of a URL. If `Some`, the suggestion can be dismissed by passing /// the wrapped string to [crate::SuggestStore::dismiss_suggestion]. dismissal_key: Option, score: f64, }, } /// Additional data about how an FTS match was made #[derive(Debug, Clone, PartialEq, uniffi::Record)] pub struct FtsMatchInfo { /// Was this a prefix match (`water b` matched against `water bottle`) pub prefix: bool, /// Did the match require stemming? (`run shoes` matched against `running shoes`) pub stemming: bool, } impl PartialOrd for Suggestion { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Suggestion { fn cmp(&self, other: &Self) -> std::cmp::Ordering { other .score() .partial_cmp(&self.score()) .unwrap_or(std::cmp::Ordering::Equal) } } impl Suggestion { /// Get the suggestion's dismissal key, which should be stored in the /// `dismissed_suggestions` table when the suggestion is dismissed. Some /// suggestions may not have dismissal keys and cannot be dismissed. pub fn dismissal_key(&self) -> Option<&str> { match self { Self::Amp { full_keyword, .. } => { if !full_keyword.is_empty() { Some(full_keyword) } else { self.raw_url() } } Self::Dynamic { dismissal_key, .. } => dismissal_key.as_deref(), Self::Wikipedia { .. } | Self::Amo { .. } | Self::Yelp { .. } | Self::Mdn { .. } | Self::Weather { .. } | Self::Fakespot { .. } => self.raw_url(), } } /// Get the URL for this suggestion, if present pub fn url(&self) -> Option<&str> { match self { Self::Amp { url, .. } | Self::Wikipedia { url, .. } | Self::Amo { url, .. } | Self::Yelp { url, .. } | Self::Mdn { url, .. } | Self::Fakespot { url, .. } => Some(url), Self::Weather { .. } | Self::Dynamic { .. } => None, } } /// Get the raw URL for this suggestion, if present /// /// This is the same as `url` except for Amp. In that case, `url` is the URL after being /// "cooked" using template interpolation, while `raw_url` is the URL template. pub fn raw_url(&self) -> Option<&str> { match self { Self::Amp { raw_url, .. } => Some(raw_url), Self::Wikipedia { .. } | Self::Amo { .. } | Self::Yelp { .. } | Self::Mdn { .. } | Self::Weather { .. } | Self::Fakespot { .. } | Self::Dynamic { .. } => self.url(), } } pub fn title(&self) -> &str { match self { Self::Amp { title, .. } | Self::Wikipedia { title, .. } | Self::Amo { title, .. } | Self::Yelp { title, .. } | Self::Mdn { title, .. } | Self::Fakespot { title, .. } => title, _ => "untitled", } } pub fn icon_data(&self) -> Option<&[u8]> { match self { Self::Amp { icon, .. } | Self::Wikipedia { icon, .. } | Self::Yelp { icon, .. } | Self::Fakespot { icon, .. } => icon.as_deref(), _ => None, } } pub fn score(&self) -> f64 { match self { Self::Amp { score, .. } | Self::Amo { score, .. } | Self::Yelp { score, .. } | Self::Mdn { score, .. } | Self::Weather { score, .. } | Self::Fakespot { score, .. } | Self::Dynamic { score, .. } => *score, Self::Wikipedia { .. } => DEFAULT_SUGGESTION_SCORE, } } pub fn fts_match_info(&self) -> Option<&FtsMatchInfo> { match self { Self::Fakespot { match_info, .. } => match_info.as_ref(), _ => None, } } } #[cfg(test)] /// Testing utilitise impl Suggestion { pub fn with_fakespot_keyword_bonus(mut self) -> Self { match &mut self { Self::Fakespot { score, .. } => { *score += 0.01; } _ => panic!("Not Suggestion::Fakespot"), } self } pub fn with_fakespot_product_type_bonus(mut self, bonus: f64) -> Self { match &mut self { Self::Fakespot { score, .. } => { *score += 0.001 * bonus; } _ => panic!("Not Suggestion::Fakespot"), } self } } impl Eq for Suggestion {} /// Replaces all template parameters in a "raw" sponsored suggestion URL, /// producing a "cooked" URL with real values. pub(crate) fn cook_raw_suggestion_url(raw_url: &str) -> String { let timestamp = Local::now().format("%Y%m%d%H").to_string(); debug_assert!(timestamp.len() == TIMESTAMP_LENGTH); // "Raw" sponsored suggestion URLs must not contain more than one timestamp // template parameter, so we replace just the first occurrence. raw_url.replacen(TIMESTAMP_TEMPLATE, ×tamp, 1) } /// Determines whether a "raw" sponsored suggestion URL is equivalent to a /// "cooked" URL. The two URLs are equivalent if they are identical except for /// their replaced template parameters, which can be different. #[uniffi::export] pub fn raw_suggestion_url_matches(raw_url: &str, cooked_url: &str) -> bool { let Some((raw_url_prefix, raw_url_suffix)) = raw_url.split_once(TIMESTAMP_TEMPLATE) else { return raw_url == cooked_url; }; let (Some(cooked_url_prefix), Some(cooked_url_suffix)) = ( cooked_url.get(..raw_url_prefix.len()), cooked_url.get(raw_url_prefix.len() + TIMESTAMP_LENGTH..), ) else { return false; }; if raw_url_prefix != cooked_url_prefix || raw_url_suffix != cooked_url_suffix { return false; } let maybe_timestamp = &cooked_url[raw_url_prefix.len()..raw_url_prefix.len() + TIMESTAMP_LENGTH]; maybe_timestamp.bytes().all(|b| b.is_ascii_digit()) } #[cfg(test)] mod tests { use super::*; #[test] fn cook_url_with_template_parameters() { let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%"; let cooked_url_with_one_timestamp = cook_raw_suggestion_url(raw_url_with_one_timestamp); assert_eq!( cooked_url_with_one_timestamp.len(), raw_url_with_one_timestamp.len() - 2 ); assert_ne!(raw_url_with_one_timestamp, cooked_url_with_one_timestamp); let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c"; let cooked_url_with_trailing_segment = cook_raw_suggestion_url(raw_url_with_trailing_segment); assert_eq!( cooked_url_with_trailing_segment.len(), raw_url_with_trailing_segment.len() - 2 ); assert_ne!( raw_url_with_trailing_segment, cooked_url_with_trailing_segment ); } #[test] fn cook_url_without_template_parameters() { let raw_url_without_timestamp = "https://example.com?b=c"; let cooked_url_without_timestamp = cook_raw_suggestion_url(raw_url_without_timestamp); assert_eq!(raw_url_without_timestamp, cooked_url_without_timestamp); } #[test] fn url_with_template_parameters_matches() { let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%"; let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c"; // Equivalent, except for their replaced template parameters. assert!(raw_suggestion_url_matches( raw_url_with_one_timestamp, "https://example.com?a=0000000000" )); assert!(raw_suggestion_url_matches( raw_url_with_trailing_segment, "https://example.com?a=1111111111&b=c" )); // Different lengths. assert!(!raw_suggestion_url_matches( raw_url_with_one_timestamp, "https://example.com?a=1234567890&c=d" )); assert!(!raw_suggestion_url_matches( raw_url_with_one_timestamp, "https://example.com?a=123456789" )); assert!(!raw_suggestion_url_matches( raw_url_with_trailing_segment, "https://example.com?a=0987654321" )); assert!(!raw_suggestion_url_matches( raw_url_with_trailing_segment, "https://example.com?a=0987654321&b=c&d=e" )); // Different query parameter names. assert!(!raw_suggestion_url_matches( raw_url_with_one_timestamp, // `a`. "https://example.com?b=4444444444" // `b`. )); assert!(!raw_suggestion_url_matches( raw_url_with_trailing_segment, // `a&b`. "https://example.com?a=5555555555&c=c" // `a&c`. )); // Not a timestamp. assert!(!raw_suggestion_url_matches( raw_url_with_one_timestamp, "https://example.com?a=bcdefghijk" )); assert!(!raw_suggestion_url_matches( raw_url_with_trailing_segment, "https://example.com?a=bcdefghijk&b=c" )); } #[test] fn url_without_template_parameters_matches() { let raw_url_without_timestamp = "https://example.com?b=c"; assert!(raw_suggestion_url_matches( raw_url_without_timestamp, "https://example.com?b=c" )); assert!(!raw_suggestion_url_matches( raw_url_without_timestamp, "http://example.com" )); assert!(!raw_suggestion_url_matches( raw_url_without_timestamp, // `a`. "http://example.com?a=c" // `b`. )); assert!(!raw_suggestion_url_matches( raw_url_without_timestamp, "https://example.com?b=c&d=e" )); } }