1use chrono::Local;
7
8use crate::{db::DEFAULT_SUGGESTION_SCORE, geoname::Geoname};
9
10const TIMESTAMP_TEMPLATE: &str = "%YYYYMMDDHH%";
12
13const TIMESTAMP_LENGTH: usize = 10;
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, uniffi::Enum)]
21#[repr(u8)]
22pub enum YelpSubjectType {
23 Service = 0,
25 Business = 1,
27}
28
29#[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 impression_url: String,
43 click_url: String,
44 raw_click_url: String,
45 score: f64,
46 fts_match_info: Option<FtsMatchInfo>,
47 },
48 Wikipedia {
49 title: String,
50 url: String,
51 icon: Option<Vec<u8>>,
52 icon_mimetype: Option<String>,
53 full_keyword: String,
54 },
55 Amo {
56 title: String,
57 url: String,
58 icon_url: String,
59 description: String,
60 rating: Option<String>,
61 number_of_ratings: i64,
62 guid: String,
63 score: f64,
64 },
65 Yelp {
66 url: String,
67 title: String,
68 icon: Option<Vec<u8>>,
69 icon_mimetype: Option<String>,
70 score: f64,
71 has_location_sign: bool,
72 subject_exact_match: bool,
73 subject_type: YelpSubjectType,
74 location_param: String,
75 },
76 Mdn {
77 title: String,
78 url: String,
79 description: String,
80 score: f64,
81 },
82 Weather {
83 city: Option<Geoname>,
84 score: f64,
85 },
86 Fakespot {
87 fakespot_grade: String,
88 product_id: String,
89 rating: f64,
90 title: String,
91 total_reviews: i64,
92 url: String,
93 icon: Option<Vec<u8>>,
94 icon_mimetype: Option<String>,
95 score: f64,
96 match_info: Option<FtsMatchInfo>,
100 },
101 Dynamic {
102 suggestion_type: String,
103 data: Option<serde_json::Value>,
104 dismissal_key: Option<String>,
109 score: f64,
110 },
111}
112
113#[derive(Debug, Clone, PartialEq, uniffi::Record)]
115pub struct FtsMatchInfo {
116 pub prefix: bool,
118 pub stemming: bool,
120}
121
122impl PartialOrd for Suggestion {
123 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
124 Some(self.cmp(other))
125 }
126}
127
128impl Ord for Suggestion {
129 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
130 other
131 .score()
132 .partial_cmp(&self.score())
133 .unwrap_or(std::cmp::Ordering::Equal)
134 }
135}
136
137impl Suggestion {
138 pub fn dismissal_key(&self) -> Option<&str> {
142 match self {
143 Self::Amp { full_keyword, .. } => {
144 if !full_keyword.is_empty() {
145 Some(full_keyword)
146 } else {
147 self.raw_url()
148 }
149 }
150 Self::Dynamic { dismissal_key, .. } => dismissal_key.as_deref(),
151 Self::Wikipedia { .. }
152 | Self::Amo { .. }
153 | Self::Yelp { .. }
154 | Self::Mdn { .. }
155 | Self::Weather { .. }
156 | Self::Fakespot { .. } => self.raw_url(),
157 }
158 }
159
160 pub fn url(&self) -> Option<&str> {
162 match self {
163 Self::Amp { url, .. }
164 | Self::Wikipedia { url, .. }
165 | Self::Amo { url, .. }
166 | Self::Yelp { url, .. }
167 | Self::Mdn { url, .. }
168 | Self::Fakespot { url, .. } => Some(url),
169 Self::Weather { .. } | Self::Dynamic { .. } => None,
170 }
171 }
172
173 pub fn raw_url(&self) -> Option<&str> {
178 match self {
179 Self::Amp { raw_url, .. } => Some(raw_url),
180 Self::Wikipedia { .. }
181 | Self::Amo { .. }
182 | Self::Yelp { .. }
183 | Self::Mdn { .. }
184 | Self::Weather { .. }
185 | Self::Fakespot { .. }
186 | Self::Dynamic { .. } => self.url(),
187 }
188 }
189
190 pub fn title(&self) -> &str {
191 match self {
192 Self::Amp { title, .. }
193 | Self::Wikipedia { title, .. }
194 | Self::Amo { title, .. }
195 | Self::Yelp { title, .. }
196 | Self::Mdn { title, .. }
197 | Self::Fakespot { title, .. } => title,
198 _ => "untitled",
199 }
200 }
201
202 pub fn icon_data(&self) -> Option<&[u8]> {
203 match self {
204 Self::Amp { icon, .. }
205 | Self::Wikipedia { icon, .. }
206 | Self::Yelp { icon, .. }
207 | Self::Fakespot { icon, .. } => icon.as_deref(),
208 _ => None,
209 }
210 }
211
212 pub fn score(&self) -> f64 {
213 match self {
214 Self::Amp { score, .. }
215 | Self::Amo { score, .. }
216 | Self::Yelp { score, .. }
217 | Self::Mdn { score, .. }
218 | Self::Weather { score, .. }
219 | Self::Fakespot { score, .. }
220 | Self::Dynamic { score, .. } => *score,
221 Self::Wikipedia { .. } => DEFAULT_SUGGESTION_SCORE,
222 }
223 }
224
225 pub fn fts_match_info(&self) -> Option<&FtsMatchInfo> {
226 match self {
227 Self::Fakespot { match_info, .. } => match_info.as_ref(),
228 _ => None,
229 }
230 }
231}
232
233#[cfg(test)]
234impl Suggestion {
236 pub fn with_fakespot_keyword_bonus(mut self) -> Self {
237 match &mut self {
238 Self::Fakespot { score, .. } => {
239 *score += 0.01;
240 }
241 _ => panic!("Not Suggestion::Fakespot"),
242 }
243 self
244 }
245
246 pub fn with_fakespot_product_type_bonus(mut self, bonus: f64) -> Self {
247 match &mut self {
248 Self::Fakespot { score, .. } => {
249 *score += 0.001 * bonus;
250 }
251 _ => panic!("Not Suggestion::Fakespot"),
252 }
253 self
254 }
255}
256
257impl Eq for Suggestion {}
258pub(crate) fn cook_raw_suggestion_url(raw_url: &str) -> String {
261 let timestamp = Local::now().format("%Y%m%d%H").to_string();
262 debug_assert!(timestamp.len() == TIMESTAMP_LENGTH);
263 raw_url.replacen(TIMESTAMP_TEMPLATE, ×tamp, 1)
266}
267
268#[uniffi::export]
272pub fn raw_suggestion_url_matches(raw_url: &str, cooked_url: &str) -> bool {
273 let Some((raw_url_prefix, raw_url_suffix)) = raw_url.split_once(TIMESTAMP_TEMPLATE) else {
274 return raw_url == cooked_url;
275 };
276 let (Some(cooked_url_prefix), Some(cooked_url_suffix)) = (
277 cooked_url.get(..raw_url_prefix.len()),
278 cooked_url.get(raw_url_prefix.len() + TIMESTAMP_LENGTH..),
279 ) else {
280 return false;
281 };
282 if raw_url_prefix != cooked_url_prefix || raw_url_suffix != cooked_url_suffix {
283 return false;
284 }
285 let maybe_timestamp =
286 &cooked_url[raw_url_prefix.len()..raw_url_prefix.len() + TIMESTAMP_LENGTH];
287 maybe_timestamp.bytes().all(|b| b.is_ascii_digit())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn cook_url_with_template_parameters() {
296 let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
297 let cooked_url_with_one_timestamp = cook_raw_suggestion_url(raw_url_with_one_timestamp);
298 assert_eq!(
299 cooked_url_with_one_timestamp.len(),
300 raw_url_with_one_timestamp.len() - 2
301 );
302 assert_ne!(raw_url_with_one_timestamp, cooked_url_with_one_timestamp);
303
304 let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
305 let cooked_url_with_trailing_segment =
306 cook_raw_suggestion_url(raw_url_with_trailing_segment);
307 assert_eq!(
308 cooked_url_with_trailing_segment.len(),
309 raw_url_with_trailing_segment.len() - 2
310 );
311 assert_ne!(
312 raw_url_with_trailing_segment,
313 cooked_url_with_trailing_segment
314 );
315 }
316
317 #[test]
318 fn cook_url_without_template_parameters() {
319 let raw_url_without_timestamp = "https://example.com?b=c";
320 let cooked_url_without_timestamp = cook_raw_suggestion_url(raw_url_without_timestamp);
321 assert_eq!(raw_url_without_timestamp, cooked_url_without_timestamp);
322 }
323
324 #[test]
325 fn url_with_template_parameters_matches() {
326 let raw_url_with_one_timestamp = "https://example.com?a=%YYYYMMDDHH%";
327 let raw_url_with_trailing_segment = "https://example.com?a=%YYYYMMDDHH%&b=c";
328
329 assert!(raw_suggestion_url_matches(
331 raw_url_with_one_timestamp,
332 "https://example.com?a=0000000000"
333 ));
334 assert!(raw_suggestion_url_matches(
335 raw_url_with_trailing_segment,
336 "https://example.com?a=1111111111&b=c"
337 ));
338
339 assert!(!raw_suggestion_url_matches(
341 raw_url_with_one_timestamp,
342 "https://example.com?a=1234567890&c=d"
343 ));
344 assert!(!raw_suggestion_url_matches(
345 raw_url_with_one_timestamp,
346 "https://example.com?a=123456789"
347 ));
348 assert!(!raw_suggestion_url_matches(
349 raw_url_with_trailing_segment,
350 "https://example.com?a=0987654321"
351 ));
352 assert!(!raw_suggestion_url_matches(
353 raw_url_with_trailing_segment,
354 "https://example.com?a=0987654321&b=c&d=e"
355 ));
356
357 assert!(!raw_suggestion_url_matches(
359 raw_url_with_one_timestamp, "https://example.com?b=4444444444" ));
362 assert!(!raw_suggestion_url_matches(
363 raw_url_with_trailing_segment, "https://example.com?a=5555555555&c=c" ));
366
367 assert!(!raw_suggestion_url_matches(
369 raw_url_with_one_timestamp,
370 "https://example.com?a=bcdefghijk"
371 ));
372 assert!(!raw_suggestion_url_matches(
373 raw_url_with_trailing_segment,
374 "https://example.com?a=bcdefghijk&b=c"
375 ));
376 }
377
378 #[test]
379 fn url_without_template_parameters_matches() {
380 let raw_url_without_timestamp = "https://example.com?b=c";
381
382 assert!(raw_suggestion_url_matches(
383 raw_url_without_timestamp,
384 "https://example.com?b=c"
385 ));
386 assert!(!raw_suggestion_url_matches(
387 raw_url_without_timestamp,
388 "http://example.com"
389 ));
390 assert!(!raw_suggestion_url_matches(
391 raw_url_without_timestamp, "http://example.com?a=c" ));
394 assert!(!raw_suggestion_url_matches(
395 raw_url_without_timestamp,
396 "https://example.com?b=c&d=e"
397 ));
398 }
399}