use unicode_normalization::UnicodeNormalization;
use super::AUTHORS_COMPARED;
pub fn normalise(s: &str) -> String {
let folded: String = s
.nfkd()
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
.flat_map(char::to_lowercase)
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
.collect();
folded.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(super) fn words(s: &str) -> Vec<String> {
normalise(s)
.split(' ')
.filter(|w| !w.is_empty())
.map(str::to_owned)
.collect()
}
pub fn unmarked(title: &str) -> &str {
let trimmed = title.trim_start();
for marker in ["retracted article", "retracted", "withdrawn"] {
if let Some(head) = trimmed.get(..marker.len())
&& head.eq_ignore_ascii_case(marker)
{
let rest = &trimmed[marker.len()..];
if let Some(rest) = rest.trim_start().strip_prefix([':', '.', '-', '—']) {
return rest.trim_start();
}
}
}
title
}
pub fn title_similarity(a: &str, b: &str) -> f64 {
let (a, b) = (unmarked(a), unmarked(b));
[(a, b), (main_title(a), b), (a, main_title(b))]
.into_iter()
.map(|(x, y)| dice(&content_words(x), &content_words(y)))
.fold(0.0, f64::max)
}
pub(super) fn dice(a: &[String], b: &[String]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.0;
}
let shared = a.iter().filter(|w| b.contains(w)).count();
2.0 * shared as f64 / (a.len() + b.len()) as f64
}
pub(super) const STOPWORDS: &[&str] = &[
"a", "an", "the", "of", "in", "on", "for", "and", "or", "to", "with", "by", "at", "from", "is",
"are", "as", "via", "into", "its",
];
pub(super) fn content_words(title: &str) -> Vec<String> {
let all = dedup(words(title));
let content: Vec<String> = all
.iter()
.filter(|w| !STOPWORDS.contains(&w.as_str()))
.cloned()
.collect();
if content.is_empty() { all } else { content }
}
pub(super) fn main_title(title: &str) -> &str {
title
.find([':', '\u{2014}', '\u{2013}'])
.map_or(title, |i| title[..i].trim())
}
pub(super) fn dedup(mut v: Vec<String>) -> Vec<String> {
v.sort_unstable();
v.dedup();
v
}
pub(super) struct Normalised {
padded: String,
pub(super) words: Vec<String>,
}
impl Normalised {
pub(super) fn new(text: &str) -> Self {
let norm = normalise(text);
Normalised {
words: norm.split(' ').map(str::to_owned).collect(),
padded: format!(" {norm} "),
}
}
pub(super) fn title_coverage(&self, title: &str) -> f64 {
let title = unmarked(title);
let phrase = |t: &str| {
let norm = normalise(t);
!norm.is_empty() && self.padded.contains(&format!(" {norm} "))
};
if phrase(title) || (main_title(title) != title && phrase(main_title(title))) {
return 1.0;
}
let title_words = content_words(title);
if title_words.is_empty() {
return 0.0;
}
let present = title_words
.iter()
.filter(|w| self.words.contains(w))
.count();
let share = present as f64 / title_words.len() as f64;
if share < 2.0 / 3.0 {
share / 2.0
} else {
share
}
}
pub(super) fn author_coverage(&self, families: &[String]) -> f64 {
let present = |f: &String| self.padded.contains(&format!(" {f} "));
let Some((first, rest)) = families.split_first() else {
return 0.0;
};
let rest = &rest[..rest.len().min(AUTHORS_COMPARED - 1)];
let rest_present = rest.iter().filter(|f| present(f)).count();
match (present(first), rest.len()) {
(true, 0) => 1.0,
(true, n) => 0.9 + 0.1 * rest_present as f64 / n as f64,
(false, 0) => 0.0,
(false, n) => 0.5 * rest_present as f64 / n as f64,
}
}
pub(super) fn years(&self) -> Vec<i16> {
self.words
.iter()
.filter(|w| w.len() == 4)
.filter_map(|w| w.parse::<i16>().ok())
.filter(|y| (1600..=2100).contains(y))
.collect()
}
}
pub fn shared_title(candidate_title: &str, reference: &str) -> Option<String> {
let (title, text) = (words(unmarked(candidate_title)), words(reference));
let mut best = (0, 0); let mut prev = vec![0usize; text.len() + 1];
for (i, tw) in title.iter().enumerate() {
let mut cur = vec![0usize; text.len() + 1];
for (j, rw) in text.iter().enumerate() {
if tw == rw {
cur[j + 1] = prev[j] + 1;
if cur[j + 1] > best.0 {
best = (cur[j + 1], i + 1);
}
}
}
prev = cur;
}
let (len, end) = best;
if len == 0 || (len < 3 && len < title.len()) {
return None;
}
Some(title[end - len..end].join(" "))
}
pub fn search_terms(text: &str) -> String {
words(text)
.into_iter()
.filter(|w| w.chars().count() > 1)
.filter(|w| !w.chars().all(|c| c.is_ascii_digit()))
.filter(|w| !matches!(w.as_str(), "et" | "al" | "doi" | "vol" | "pp" | "no"))
.collect::<Vec<_>>()
.join(" ")
}