use crate::index::{Clip, Index, Segment};
#[derive(Debug, Clone, PartialEq)]
pub struct Hit<'a> {
pub clip: &'a Clip,
pub start: f64,
pub end: f64,
pub context: String,
}
pub fn fold(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars().flat_map(char::to_lowercase) {
let c = match c {
'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
'ç' | 'ć' | 'č' => 'c',
'è'..='ë' | 'ē' | 'ė' | 'ę' | 'ě' => 'e',
'ì'..='ï' | 'ī' | 'į' => 'i',
'ñ' | 'ń' => 'n',
'ò'..='ö' | 'ø' | 'ō' | 'ő' => 'o',
'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
'ý' | 'ÿ' => 'y',
c => c,
};
if c.is_alphanumeric() {
out.push(c);
} else if c.is_whitespace() || c == '-' {
out.push(' ');
}
}
out
}
fn tokens(s: &str) -> Vec<String> {
fold(s).split_whitespace().map(str::to_owned).collect()
}
pub const ALIASES: &[(&str, &[&str])] = &[(
"konbini",
&[
"convenience store",
"family mart",
"familymart",
"lawson",
"seven eleven",
"7 eleven",
"combini",
"conbini",
],
)];
pub fn expand(query: &str) -> Vec<String> {
let q = tokens(query).join(" ");
let mut out = vec![q.clone()];
for (k, v) in ALIASES {
if q == *k {
out.extend(v.iter().map(|s| (*s).to_owned()));
}
}
out
}
pub fn search<'a>(index: &'a Index, query: &str) -> Vec<Hit<'a>> {
let mut hits: Vec<Hit<'a>> = expand(query)
.iter()
.flat_map(|q| search_exact(index, q))
.collect();
hits.sort_by(|a, b| a.clip.id.cmp(&b.clip.id).then(a.start.total_cmp(&b.start)));
hits.dedup_by(|a, b| a.clip.id == b.clip.id && (a.start - b.start).abs() < 1e-6);
hits
}
pub fn search_exact<'a>(index: &'a Index, query: &str) -> Vec<Hit<'a>> {
let q = tokens(query);
let mut hits = Vec::new();
if q.is_empty() {
return hits;
}
for clip in &index.clips {
let Some(t) = &clip.transcript else { continue };
for seg in &t.segments {
if seg.words.is_empty() {
if let Some(h) = segment_hit(clip, seg, &q) {
hits.push(h);
}
continue;
}
let toks: Vec<(String, usize)> = seg
.words
.iter()
.enumerate()
.flat_map(|(i, w)| tokens(&w.text).into_iter().map(move |t| (t, i)))
.collect();
let mut i = 0;
while i + q.len() <= toks.len() {
if q.iter()
.zip(&toks[i..])
.all(|(q, (t, _))| t.starts_with(q.as_str()))
{
let (a, b) = (toks[i].1, toks[i + q.len() - 1].1);
hits.push(Hit {
clip,
start: seg.words[a].start,
end: seg.words[b].end,
context: seg.text.trim().to_owned(),
});
i += q.len();
} else {
i += 1;
}
}
}
}
hits
}
fn segment_hit<'a>(clip: &'a Clip, seg: &Segment, q: &[String]) -> Option<Hit<'a>> {
let toks = tokens(&seg.text);
toks.windows(q.len())
.any(|w| w.iter().zip(q).all(|(t, q)| t.starts_with(q.as_str())))
.then(|| Hit {
clip,
start: seg.start,
end: seg.end,
context: seg.text.trim().to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::{Transcript, Word};
fn w(start: f64, text: &str) -> Word {
Word {
start,
end: start + 0.3,
text: text.into(),
prob: 1.0,
}
}
fn index() -> Index {
let seg = Segment {
start: 10.0,
end: 12.0,
text: " Let's hit the Konbini's coffee.".into(),
words: vec![
w(10.0, " Let's"),
w(10.3, " hit"),
w(10.6, " the"),
w(10.9, " Konbini's"),
w(11.2, " coffee."),
],
};
let plain = Segment {
start: 20.0,
end: 22.0,
text: "Café au lait".into(),
words: vec![],
};
Index {
clips: vec![Clip {
id: "c1".into(),
transcript: Some(Transcript {
segments: vec![seg, plain],
..Transcript::default()
}),
..Clip::default()
}],
..Index::default()
}
}
#[test]
fn finds_words_and_phrases() {
let ix = index();
let h = search(&ix, "konbini");
assert_eq!(h.len(), 1);
assert!((h[0].start - 10.9).abs() < 1e-9);
let h = search(&ix, "the KONBINI coffee");
assert_eq!(h.len(), 1);
assert!((h[0].end - 11.5).abs() < 1e-9);
assert_eq!(search(&ix, "coffee the"), []);
assert_eq!(search(&ix, "cafe").len(), 1);
assert!(expand("Konbini").contains(&"family mart".to_owned()));
}
}