suggest/
suggestion.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
4 */
5
6use chrono::Local;
7
8use crate::{db::DEFAULT_SUGGESTION_SCORE, geoname::Geoname, JsonValue};
9
10/// The template parameter for a timestamp in a "raw" sponsored suggestion URL.
11const TIMESTAMP_TEMPLATE: &str = "%YYYYMMDDHH%";
12
13/// The length, in bytes, of a timestamp in a "cooked" sponsored suggestion URL.
14///
15/// Cooked timestamps don't include the leading or trailing `%`, so this is
16/// 2 bytes shorter than [`TIMESTAMP_TEMPLATE`].
17const TIMESTAMP_LENGTH: usize = 10;
18
19/// Subject type for Yelp suggestion.
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, uniffi::Enum)]
21#[repr(u8)]
22pub enum YelpSubjectType {
23    // Service such as sushi, ramen, yoga etc.
24    Service = 0,
25    // Specific business such as the shop name.
26    Business = 1,
27}
28
29/// A suggestion from the database to show in the address bar.
30#[derive(Clone, Debug, PartialEq, uniffi::Enum)]
31pub enum Suggestion {
32    Amp {
33        title: String,
34        url: String,
35        raw_url: String,
36        icon: Option<Vec<u8>>,
37        icon_mimetype: Option<String>,
38        full_keyword: String,
39        block_id: i64,
40        advertiser: String,
41        iab_category: String,
42        categories: Vec<i32>,
43        impression_url: String,
44        click_url: String,
45        raw_click_url: String,
46        score: f64,
47        fts_match_info: Option<FtsMatchInfo>,
48        suggestion_id: String,
49    },
50    Wikipedia {
51        title: String,
52        url: String,
53        icon: Option<Vec<u8>>,
54        icon_mimetype: Option<String>,
55        full_keyword: String,
56    },
57    Amo {
58        title: String,
59        url: String,
60        icon_url: String,
61        description: String,
62        rating: Option<String>,
63        number_of_ratings: i64,
64        guid: String,
65        score: f64,
66    },
67    Yelp {
68        url: String,
69        title: String,
70        icon: Option<Vec<u8>>,
71        icon_mimetype: Option<String>,
72        score: f64,
73        has_location_sign: bool,
74        subject_exact_match: bool,
75        subject_type: YelpSubjectType,
76        location_param: String,
77    },
78    Mdn {
79        title: String,
80        url: String,
81        description: String,
82        score: f64,
83    },
84    Weather {
85        city: Option<Geoname>,
86        score: f64,
87    },
88    Dynamic {
89        suggestion_type: String,
90        data: Option<JsonValue>,
91        /// This value is optionally defined in the suggestion's remote settings
92        /// data and is an opaque token used for dismissing the suggestion in
93        /// lieu of a URL. If `Some`, the suggestion can be dismissed by passing
94        /// the wrapped string to [crate::SuggestStore::dismiss_suggestion].
95        dismissal_key: Option<String>,
96        score: f64,
97    },
98}
99
100/// Additional data about how an FTS match was made
101#[derive(Debug, Clone, PartialEq, uniffi::Record)]
102pub struct FtsMatchInfo {
103    /// Was this a prefix match (`water b` matched against `water bottle`)
104    pub prefix: bool,
105    /// Did the match require stemming? (`run shoes` matched against `running shoes`)
106    pub stemming: bool,
107}
108
109impl PartialOrd for Suggestion {
110    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
111        Some(self.cmp(other))
112    }
113}
114
115impl Ord for Suggestion {
116    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
117        other
118            .score()
119            .partial_cmp(&self.score())
120            .unwrap_or(std::cmp::Ordering::Equal)
121    }
122}
123
124impl Suggestion {
125    /// Get the suggestion's dismissal key, which should be stored in the
126    /// `dismissed_suggestions` table when the suggestion is dismissed. Some
127    /// suggestions may not have dismissal keys and cannot be dismissed.
128    pub fn dismissal_key(&self) -> Option<&str> {
129        match self {
130            Self::Amp { full_keyword, .. } => {
131                if !full_keyword.is_empty() {
132                    Some(full_keyword)
133                } else {
134                    self.raw_url()
135                }
136            }
137            Self::Dynamic { dismissal_key, .. } => dismissal_key.as_deref(),
138            Self::Wikipedia { .. }
139            | Self::Amo { .. }
140            | Self::Yelp { .. }
141            | Self::Mdn { .. }
142            | Self::Weather { .. } => self.raw_url(),
143        }
144    }
145
146    /// Get the URL for this suggestion, if present
147    pub fn url(&self) -> Option<&str> {
148        match self {
149            Self::Amp { url, .. }
150            | Self::Wikipedia { url, .. }
151            | Self::Amo { url, .. }
152            | Self::Yelp { url, .. }
153            | Self::Mdn { url, .. } => Some(url),
154            Self::Weather { .. } | Self::Dynamic { .. } => None,
155        }
156    }
157
158    /// Get the raw URL for this suggestion, if present
159    ///
160    /// This is the same as `url` except for Amp.  In that case, `url` is the URL after being
161    /// "cooked" using template interpolation, while `raw_url` is the URL template.
162    pub fn raw_url(&self) -> Option<&str> {
163        match self {
164            Self::Amp { raw_url, .. } => Some(raw_url),
165            Self::Wikipedia { .. }
166            | Self::Amo { .. }
167            | Self::Yelp { .. }
168            | Self::Mdn { .. }
169            | Self::Weather { .. }
170            | Self::Dynamic { .. } => self.url(),
171        }
172    }
173
174    pub fn title(&self) -> &str {
175        match self {
176            Self::Amp { title, .. }
177            | Self::Wikipedia { title, .. }
178            | Self::Amo { title, .. }
179            | Self::Yelp { title, .. }
180            | Self::Mdn { title, .. } => title,
181            _ => "untitled",
182        }
183    }
184
185    pub fn icon_data(&self) -> Option<&[u8]> {
186        match self {
187            Self::Amp { icon, .. } | Self::Wikipedia { icon, .. } | Self::Yelp { icon, .. } => {
188                icon.as_deref()
189            }
190            _ => None,
191        }
192    }
193
194    pub fn score(&self) -> f64 {
195        match self {
196            Self::Amp { score, .. }
197            | Self::Amo { score, .. }
198            | Self::Yelp { score, .. }
199            | Self::Mdn { score, .. }
200            | Self::Weather { score, .. }
201            | Self::Dynamic { score, .. } => *score,
202            Self::Wikipedia { .. } => DEFAULT_SUGGESTION_SCORE,
203        }
204    }
205
206    pub fn fts_match_info(&self) -> Option<&FtsMatchInfo> {
207        None
208    }
209}
210
211impl Eq for Suggestion {}
212/// Replaces all template parameters in a "raw" sponsored suggestion URL,
213/// producing a "cooked" URL with real values.
214pub(crate) fn cook_raw_suggestion_url(raw_url: &str) -> String {
215    let timestamp = Local::now().format("%Y%m%d%H").to_string();
216    debug_assert!(timestamp.len() == TIMESTAMP_LENGTH);
217    // "Raw" sponsored suggestion URLs must not contain more than one timestamp
218    // template parameter, so we replace just the first occurrence.
219    raw_url.replacen(TIMESTAMP_TEMPLATE, &timestamp, 1)
220}
221
222/// Determines whether a "raw" sponsored suggestion URL is equivalent to a
223/// "cooked" URL. The two URLs are equivalent if they are identical except for
224/// their replaced template parameters, which can be different.
225#[uniffi::export]
226pub fn raw_suggestion_url_matches(raw_url: &str, cooked_url: &str) -> bool {
227    let Some((raw_url_prefix, raw_url_suffix)) = raw_url.split_once(TIMESTAMP_TEMPLATE) else {
228        return raw_url == cooked_url;
229    };
230    let (Some(cooked_url_prefix), Some(cooked_url_suffix)) = (
231        cooked_url.get(..raw_url_prefix.len()),
232        cooked_url.get(raw_url_prefix.len() + TIMESTAMP_LENGTH..),
233    ) else {
234        return false;
235    };
236    if raw_url_prefix != cooked_url_prefix || raw_url_suffix != cooked_url_suffix {
237        return false;
238    }
239    let maybe_timestamp =
240        &cooked_url[raw_url_prefix.len()..raw_url_prefix.len() + TIMESTAMP_LENGTH];
241    maybe_timestamp.bytes().all(|b| b.is_ascii_digit())
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn cook_url_with_template_parameters() {
250        let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
251        let cooked_url_with_one_timestamp = cook_raw_suggestion_url(raw_url_with_one_timestamp);
252        assert_eq!(
253            cooked_url_with_one_timestamp.len(),
254            raw_url_with_one_timestamp.len() - 2
255        );
256        assert_ne!(raw_url_with_one_timestamp, cooked_url_with_one_timestamp);
257
258        let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
259        let cooked_url_with_trailing_segment =
260            cook_raw_suggestion_url(raw_url_with_trailing_segment);
261        assert_eq!(
262            cooked_url_with_trailing_segment.len(),
263            raw_url_with_trailing_segment.len() - 2
264        );
265        assert_ne!(
266            raw_url_with_trailing_segment,
267            cooked_url_with_trailing_segment
268        );
269    }
270
271    #[test]
272    fn cook_url_without_template_parameters() {
273        let raw_url_without_timestamp = "https://example.com?b=c";
274        let cooked_url_without_timestamp = cook_raw_suggestion_url(raw_url_without_timestamp);
275        assert_eq!(raw_url_without_timestamp, cooked_url_without_timestamp);
276    }
277
278    #[test]
279    fn url_with_template_parameters_matches() {
280        let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
281        let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
282
283        // Equivalent, except for their replaced template parameters.
284        assert!(raw_suggestion_url_matches(
285            raw_url_with_one_timestamp,
286            "https://example.com?a=0000000000"
287        ));
288        assert!(raw_suggestion_url_matches(
289            raw_url_with_trailing_segment,
290            "https://example.com?a=1111111111&b=c"
291        ));
292
293        // Different lengths.
294        assert!(!raw_suggestion_url_matches(
295            raw_url_with_one_timestamp,
296            "https://example.com?a=1234567890&c=d"
297        ));
298        assert!(!raw_suggestion_url_matches(
299            raw_url_with_one_timestamp,
300            "https://example.com?a=123456789"
301        ));
302        assert!(!raw_suggestion_url_matches(
303            raw_url_with_trailing_segment,
304            "https://example.com?a=0987654321"
305        ));
306        assert!(!raw_suggestion_url_matches(
307            raw_url_with_trailing_segment,
308            "https://example.com?a=0987654321&b=c&d=e"
309        ));
310
311        // Different query parameter names.
312        assert!(!raw_suggestion_url_matches(
313            raw_url_with_one_timestamp,         // `a`.
314            "https://example.com?b=4444444444"  // `b`.
315        ));
316        assert!(!raw_suggestion_url_matches(
317            raw_url_with_trailing_segment,          // `a&b`.
318            "https://example.com?a=5555555555&c=c"  // `a&c`.
319        ));
320
321        // Not a timestamp.
322        assert!(!raw_suggestion_url_matches(
323            raw_url_with_one_timestamp,
324            "https://example.com?a=bcdefghijk"
325        ));
326        assert!(!raw_suggestion_url_matches(
327            raw_url_with_trailing_segment,
328            "https://example.com?a=bcdefghijk&b=c"
329        ));
330    }
331
332    #[test]
333    fn url_without_template_parameters_matches() {
334        let raw_url_without_timestamp = "https://example.com?b=c";
335
336        assert!(raw_suggestion_url_matches(
337            raw_url_without_timestamp,
338            "https://example.com?b=c"
339        ));
340        assert!(!raw_suggestion_url_matches(
341            raw_url_without_timestamp,
342            "http://example.com"
343        ));
344        assert!(!raw_suggestion_url_matches(
345            raw_url_without_timestamp, // `a`.
346            "http://example.com?a=c"   // `b`.
347        ));
348        assert!(!raw_suggestion_url_matches(
349            raw_url_without_timestamp,
350            "https://example.com?b=c&d=e"
351        ));
352    }
353}