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