Files
Starlight_Lancher/packages/app-lib/src/api/content_search.rs

1290 lines
42 KiB
Rust

use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use serde::{Deserialize, Serialize};
use zhconv::{Variant, zhconv};
const WIKI_ENTRIES_DATA: &str = include_str!("content_search/WikiEntries.txt");
const SEARCHER_WORDS_DATA: &str =
include_str!("content_search/searcher_words.txt");
const RADIX_DIGITS: &str = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz/+=!?@#$%^&*()[]{}<>;:',";
const MAX_MATCHES: usize = 100;
const MIN_SIMILARITY: f64 = 0.25;
/// Compact queries shorter than this are never worth segmenting; they are
/// either a single known word or too ambiguous to split safely.
const MIN_COMPACT_QUERY_LENGTH: usize = 6;
static WIKI_ENTRIES: LazyLock<Vec<WikiEntry>> =
LazyLock::new(|| parse_wiki_entries(WIKI_ENTRIES_DATA));
/// Segmentation dictionary, sorted longest-first so greedy prefix matching
/// prefers the longest known word at every step.
static SEARCHER_WORDS: LazyLock<Vec<String>> = LazyLock::new(|| {
let mut words = SEARCHER_WORDS_DATA
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter(|line| {
line.chars()
.all(|character| character.is_ascii_alphabetic())
})
.map(str::to_lowercase)
.collect::<Vec<_>>();
words.sort();
words.dedup();
words.sort_by(|left, right| right.len().cmp(&left.len()));
words
});
static CHINESE_NAME_INDEX: LazyLock<ChineseNameIndex> =
LazyLock::new(|| build_chinese_name_index(&WIKI_ENTRIES));
static WIKI_ID_INDEX: LazyLock<WikiIdIndex> =
LazyLock::new(|| build_wiki_id_index(&WIKI_ENTRIES));
#[derive(Clone, Debug)]
struct WikiEntry {
/// MC 百科 (mcmod.cn) class ID, encoded as the entry's 1-based line
/// number in `WikiEntries.txt`; empty lines reserve the IDs of entries
/// without a platform slug.
wiki_id: u32,
chinese_name: Option<String>,
curseforge_slug: Option<String>,
modrinth_slug: Option<String>,
popularity: u32,
}
#[derive(Clone, Copy)]
enum Platform {
CurseForge,
Modrinth,
}
#[derive(Clone, Debug)]
struct SearchMatch<'a> {
entry: &'a WikiEntry,
similarity: f64,
exact: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChineseSearchTranslation {
pub chinese_name: String,
pub curseforge_slug: Option<String>,
pub modrinth_slug: Option<String>,
pub match_score: f64,
pub exact: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChineseSearchResolution {
pub is_chinese: bool,
pub normalized_query: String,
pub curseforge_query: Option<String>,
pub modrinth_query: Option<String>,
pub modrinth_slugs: Vec<String>,
pub translations: Vec<ChineseSearchTranslation>,
}
#[derive(Debug, Default)]
struct ChineseNameIndex {
modrinth: HashMap<String, String>,
curseforge: HashMap<String, String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChineseNameLookup {
pub modrinth: HashMap<String, String>,
pub curseforge: HashMap<String, String>,
}
/// Batch-resolves Chinese display names for known platform slugs, keyed by
/// each input slug exactly as it was passed in.
pub fn lookup_chinese_content_names(
modrinth_slugs: &[String],
curseforge_slugs: &[String],
) -> ChineseNameLookup {
ChineseNameLookup {
modrinth: collect_chinese_names(
modrinth_slugs,
&CHINESE_NAME_INDEX.modrinth,
),
curseforge: collect_chinese_names(
curseforge_slugs,
&CHINESE_NAME_INDEX.curseforge,
),
}
}
fn collect_chinese_names(
slugs: &[String],
index: &HashMap<String, String>,
) -> HashMap<String, String> {
slugs
.iter()
.filter_map(|slug| {
index
.get(&slug.to_lowercase())
.map(|name| (slug.clone(), name.clone()))
})
.collect()
}
#[derive(Debug, Default)]
struct WikiIdIndex {
modrinth: HashMap<String, u32>,
curseforge: HashMap<String, u32>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikiIdLookup {
pub modrinth: HashMap<String, u32>,
pub curseforge: HashMap<String, u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentIdentityCounterpart {
pub provider: String,
pub project_id: String,
pub slug: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentIdentityRecord {
pub key: String,
pub counterparts: Vec<ContentIdentityCounterpart>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentIdentityLookup {
pub modrinth: HashMap<String, Vec<ContentIdentityRecord>>,
pub curseforge: HashMap<String, Vec<ContentIdentityRecord>>,
}
/// Resolves curated cross-platform identities for project slugs.
///
/// Only entries that contain both a CurseForge and a Modrinth slug are
/// returned. A slug may have more than one candidate identity; callers must
/// treat that result as ambiguous instead of hard-blocking an installation.
pub fn lookup_content_identities(
modrinth_slugs: &[String],
curseforge_slugs: &[String],
) -> ContentIdentityLookup {
let requested_modrinth = modrinth_slugs
.iter()
.map(|slug| (slug.to_lowercase(), slug.clone()))
.collect::<HashMap<_, _>>();
let requested_curseforge = curseforge_slugs
.iter()
.map(|slug| (slug.to_lowercase(), slug.clone()))
.collect::<HashMap<_, _>>();
let mut modrinth = HashMap::<String, Vec<ContentIdentityRecord>>::new();
let mut curseforge = HashMap::<String, Vec<ContentIdentityRecord>>::new();
for entry in WIKI_ENTRIES.iter() {
let (Some(curseforge_slug), Some(modrinth_slug)) = (
entry.curseforge_slug.as_deref(),
entry.modrinth_slug.as_deref(),
) else {
continue;
};
let key = format!("mapping:{}", entry.wiki_id);
let counterparts = vec![
ContentIdentityCounterpart {
provider: "curseforge".to_string(),
project_id: curseforge_slug.to_string(),
slug: curseforge_slug.to_string(),
},
ContentIdentityCounterpart {
provider: "modrinth".to_string(),
project_id: modrinth_slug.to_string(),
slug: modrinth_slug.to_string(),
},
];
let record = ContentIdentityRecord {
key: key.clone(),
counterparts,
};
if let Some(original) =
requested_modrinth.get(&modrinth_slug.to_lowercase())
{
push_identity_record(&mut modrinth, original, &record);
}
if let Some(original) =
requested_curseforge.get(&curseforge_slug.to_lowercase())
{
push_identity_record(&mut curseforge, original, &record);
}
}
ContentIdentityLookup {
modrinth,
curseforge,
}
}
fn push_identity_record(
index: &mut HashMap<String, Vec<ContentIdentityRecord>>,
slug: &str,
record: &ContentIdentityRecord,
) {
let records = index.entry(slug.to_string()).or_default();
if records.iter().any(|candidate| candidate.key == record.key) {
return;
}
records.push(record.clone());
records.sort_by(|left, right| left.key.cmp(&right.key));
}
/// Batch-resolves MC 百科 (mcmod.cn) class IDs for known platform slugs,
/// keyed by each input slug exactly as it was passed in.
pub fn lookup_content_wiki_ids(
modrinth_slugs: &[String],
curseforge_slugs: &[String],
) -> WikiIdLookup {
WikiIdLookup {
modrinth: collect_wiki_ids(modrinth_slugs, &WIKI_ID_INDEX.modrinth),
curseforge: collect_wiki_ids(
curseforge_slugs,
&WIKI_ID_INDEX.curseforge,
),
}
}
fn collect_wiki_ids(
slugs: &[String],
index: &HashMap<String, u32>,
) -> HashMap<String, u32> {
slugs
.iter()
.filter_map(|slug| {
index
.get(&slug.to_lowercase())
.map(|wiki_id| (slug.clone(), *wiki_id))
})
.collect()
}
fn build_wiki_id_index(entries: &[WikiEntry]) -> WikiIdIndex {
let mut modrinth = HashMap::<String, (u32, u32)>::new();
let mut curseforge = HashMap::<String, (u32, u32)>::new();
for entry in entries {
for (slug, index) in [
(entry.modrinth_slug.as_deref(), &mut modrinth),
(entry.curseforge_slug.as_deref(), &mut curseforge),
] {
let Some(slug) = slug else {
continue;
};
let key = slug.to_lowercase();
let is_better = index
.get(&key)
.is_none_or(|(_, popularity)| *popularity < entry.popularity);
if is_better {
index.insert(key, (entry.wiki_id, entry.popularity));
}
}
}
WikiIdIndex {
modrinth: modrinth
.into_iter()
.map(|(key, (wiki_id, _))| (key, wiki_id))
.collect(),
curseforge: curseforge
.into_iter()
.map(|(key, (wiki_id, _))| (key, wiki_id))
.collect(),
}
}
fn build_chinese_name_index(entries: &[WikiEntry]) -> ChineseNameIndex {
let mut modrinth = HashMap::<String, (String, u32)>::new();
let mut curseforge = HashMap::<String, (String, u32)>::new();
for entry in entries {
let Some(chinese_name) = &entry.chinese_name else {
continue;
};
if chinese_name.is_empty() {
continue;
}
for (slug, index) in [
(entry.modrinth_slug.as_deref(), &mut modrinth),
(entry.curseforge_slug.as_deref(), &mut curseforge),
] {
let Some(slug) = slug else {
continue;
};
let key = slug.to_lowercase();
let is_better = index
.get(&key)
.is_none_or(|(_, popularity)| *popularity < entry.popularity);
if is_better {
index.insert(key, (chinese_name.clone(), entry.popularity));
}
}
}
ChineseNameIndex {
modrinth: modrinth
.into_iter()
.map(|(key, (name, _))| (key, name))
.collect(),
curseforge: curseforge
.into_iter()
.map(|(key, (name, _))| (key, name))
.collect(),
}
}
const MAX_LOCALIZED_FILE_NAME_BYTES: usize = 200;
/// Resolves the Chinese title used for `[中文名]` file-name prefixes from a
/// Modrinth project slug.
pub fn chinese_file_title_for_modrinth_slug(slug: &str) -> Option<String> {
CHINESE_NAME_INDEX
.modrinth
.get(&slug.to_lowercase())
.and_then(|name| chinese_file_title(name))
}
/// Resolves the Chinese title used for `[中文名]` file-name prefixes from a
/// CurseForge project slug.
pub fn chinese_file_title_for_curseforge_slug(slug: &str) -> Option<String> {
CHINESE_NAME_INDEX
.curseforge
.get(&slug.to_lowercase())
.and_then(|name| chinese_file_title(name))
}
/// Prefixes a downloaded content file name with `[chinese_title]`.
///
/// Returns `None` whenever renaming would be ambiguous or unsafe (the name
/// already carries a `[` prefix, the title has no Chinese characters, or the
/// result would exceed file-system name limits); callers must then keep the
/// original name.
pub fn localized_content_file_name(
file_name: &str,
chinese_title: &str,
) -> Option<String> {
if file_name.is_empty() || file_name.starts_with('[') {
return None;
}
if chinese_title.is_empty()
|| !contains_chinese(chinese_title)
|| chinese_title.contains(['[', ']', '/', '\\'])
{
return None;
}
let localized = format!("[{chinese_title}]{file_name}");
(localized.len() <= MAX_LOCALIZED_FILE_NAME_BYTES).then_some(localized)
}
/// Strips the `[中文名]` marker this app prepends to downloaded content
/// files. Names without such a marker are returned unchanged.
pub fn original_content_file_name(file_name: &str) -> &str {
let Some(rest) = file_name.strip_prefix('[') else {
return file_name;
};
let Some((title, original)) = rest.split_once(']') else {
return file_name;
};
if original.is_empty() || !contains_chinese(title) {
return file_name;
}
original
}
/// Applies `[chinese_title]` to the file-name segment of an instance-relative
/// path, keeping the directory prefix untouched.
pub fn localized_content_relative_path(
relative_path: &str,
chinese_title: &str,
) -> Option<String> {
let (directory, file_name) = match relative_path.rsplit_once('/') {
Some((directory, file_name)) => (Some(directory), file_name),
None => (None, relative_path),
};
let localized = localized_content_file_name(file_name, chinese_title)?;
Some(match directory {
Some(directory) => format!("{directory}/{localized}"),
None => localized,
})
}
/// Strips the `[中文名]` marker from the file-name segment of an
/// instance-relative path, e.g. when exporting a modpack.
pub fn original_content_relative_path(relative_path: &str) -> String {
match relative_path.rsplit_once('/') {
Some((directory, file_name)) => {
format!("{directory}/{}", original_content_file_name(file_name))
}
None => original_content_file_name(relative_path).to_string(),
}
}
fn chinese_file_title(chinese_name: &str) -> Option<String> {
const ILLEGAL_CHARACTERS: &str = r#"/\:*?"<>|[]"#;
let stripped = strip_english_alias(chinese_name);
let sanitized = stripped
.chars()
.filter(|character| {
!character.is_control() && !ILLEGAL_CHARACTERS.contains(*character)
})
.collect::<String>();
let trimmed = sanitized.trim();
(!trimmed.is_empty() && contains_chinese(trimmed))
.then(|| trimmed.to_string())
}
/// Removes the trailing ` (English Name)` alias that wiki entries append to
/// their Chinese names, mirroring the frontend `bilingualTitle` helper.
fn strip_english_alias(name: &str) -> &str {
let trimmed = name.trim_end();
let Some(without_paren) = trimmed.strip_suffix(')') else {
return trimmed;
};
let Some(open_index) = without_paren.rfind('(') else {
return trimmed;
};
let inner = &without_paren[open_index + 1..];
if inner.contains(['(', ')'])
|| !inner
.chars()
.any(|character| character.is_ascii_alphabetic())
{
return trimmed;
}
let prefix = &without_paren[..open_index];
let stripped = prefix.trim_end();
if stripped.is_empty() || stripped.len() == prefix.len() {
return trimmed;
}
stripped
}
fn contains_chinese(value: &str) -> bool {
value
.chars()
.any(|character| ('\u{4e00}'..='\u{9fbb}').contains(&character))
}
pub fn resolve_chinese_content_search(query: &str) -> ChineseSearchResolution {
let lowercase_query = query.trim().to_lowercase();
let normalized_query = zhconv(&lowercase_query, Variant::ZhCN);
let is_chinese = contains_chinese(&normalized_query);
if normalized_query.is_empty() || !is_chinese {
return ChineseSearchResolution {
is_chinese,
normalized_query,
..ChineseSearchResolution::default()
};
}
let curseforge_matches =
search_entries(&WIKI_ENTRIES, &normalized_query, Platform::CurseForge);
let modrinth_matches =
search_entries(&WIKI_ENTRIES, &normalized_query, Platform::Modrinth);
let curseforge_query = select_curseforge_query(&curseforge_matches);
let modrinth_query =
select_modrinth_query(&modrinth_matches, &normalized_query);
let modrinth_slugs = modrinth_matches
.iter()
.filter_map(|result| result.entry.modrinth_slug.clone())
.take(MAX_MATCHES)
.collect();
let mut translations = Vec::new();
let mut seen = HashSet::new();
for result in curseforge_matches.iter().chain(modrinth_matches.iter()) {
let entry = result.entry;
let key = (
entry.curseforge_slug.as_deref(),
entry.modrinth_slug.as_deref(),
);
if !seen.insert(key) {
continue;
}
let Some(chinese_name) = &entry.chinese_name else {
continue;
};
translations.push(ChineseSearchTranslation {
chinese_name: chinese_name.clone(),
curseforge_slug: entry.curseforge_slug.clone(),
modrinth_slug: entry.modrinth_slug.clone(),
match_score: result.similarity,
exact: result.exact,
});
}
ChineseSearchResolution {
is_chinese,
normalized_query,
curseforge_query,
modrinth_query,
modrinth_slugs,
translations,
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentSearchExpansion {
/// Dictionary-based segmentation of a compact (separator-free) query,
/// e.g. `sodiumextra` → `sodium extra`. `None` when the query is already
/// a single known word, cannot be fully segmented, or is too short.
pub suggested_split: Option<String>,
}
/// Expands a browse search query with the bundled segmentation dictionary.
///
/// Server-side search (Modrinth, CurseForge) tokenizes on whitespace and
/// separators, so a query typed without spaces (`sodiumextra`) cannot match a
/// project named "Sodium Extra". This resolves such queries to their
/// space-separated form whenever the whole query can be reconstructed from
/// known words; ambiguity (`xyzabc`) yields no suggestion.
pub fn expand_content_search_query(query: &str) -> ContentSearchExpansion {
let compact = query
.chars()
.filter(|character| character.is_alphanumeric())
.collect::<String>()
.to_lowercase();
ContentSearchExpansion {
suggested_split: segment_compact_query(&compact),
}
}
fn segment_compact_query(compact: &str) -> Option<String> {
if compact.len() < MIN_COMPACT_QUERY_LENGTH
|| SEARCHER_WORDS.iter().any(|word| word == compact)
{
return None;
}
let mut parts = Vec::new();
let mut rest = compact;
while !rest.is_empty() {
let Some((word, remainder)) = longest_prefix_word(rest) else {
return None;
};
parts.push(word);
rest = remainder;
}
if parts.len() < 2 || parts.iter().any(|part| part.len() < 2) {
return None;
}
Some(parts.join(" "))
}
fn longest_prefix_word<'a>(value: &'a str) -> Option<(&'a str, &'a str)> {
SEARCHER_WORDS.iter().find_map(|word| {
value.strip_prefix(word).map(|rest| (word.as_str(), rest))
})
}
fn parse_wiki_entries(source: &str) -> Vec<WikiEntry> {
let mut lines = source.lines().collect::<Vec<_>>();
let Some(popularity_data) = lines.pop() else {
return Vec::new();
};
let popularities = popularity_data
.as_bytes()
.chunks_exact(3)
.map(decode_popularity)
.collect::<Vec<_>>();
let mut popularity_iter = popularities.into_iter();
let mut results = Vec::new();
for (line_index, line) in lines.into_iter().enumerate() {
if line.is_empty() {
continue;
}
let wiki_id = line_index as u32 + 1;
let popularity = popularity_iter.next().unwrap_or_default();
for raw_entry in line.split('¨') {
let mut parts = raw_entry.split('|');
let slugs = parts.next().unwrap_or_default();
let final_part = parts.next_back();
let (curseforge_slug, modrinth_slug) = parse_slugs(slugs);
let chinese_name = final_part.map(|name| {
if name.contains('*') {
let english_name = curseforge_slug
.as_deref()
.or(modrinth_slug.as_deref())
.unwrap_or_default()
.replace('-', " ")
.split_whitespace()
.map(capitalize)
.collect::<Vec<_>>()
.join(" ");
name.replace('*', &format!(" ({english_name})"))
} else {
name.to_string()
}
});
results.push(WikiEntry {
wiki_id,
chinese_name,
curseforge_slug,
modrinth_slug,
popularity,
});
}
}
results
}
fn parse_slugs(slugs: &str) -> (Option<String>, Option<String>) {
if let Some(modrinth) = slugs.strip_prefix('@') {
return (None, non_empty(modrinth));
}
if let Some(shared) = slugs.strip_suffix('@') {
let shared = non_empty(shared);
return (shared.clone(), shared);
}
if let Some((curseforge, modrinth)) = slugs.split_once('@') {
return (non_empty(curseforge), non_empty(modrinth));
}
(non_empty(slugs), None)
}
fn non_empty(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string())
}
fn capitalize(value: &str) -> String {
let mut characters = value.chars();
match characters.next() {
Some(first) => first.to_uppercase().chain(characters).collect(),
None => String::new(),
}
}
fn decode_popularity(chunk: &[u8]) -> u32 {
chunk.iter().fold(0_u32, |value, character| {
let digit = RADIX_DIGITS
.as_bytes()
.iter()
.position(|candidate| candidate == character)
.unwrap_or_default() as u32;
value.saturating_mul(86).saturating_add(digit)
})
}
fn platform_slug(entry: &WikiEntry, platform: Platform) -> Option<&str> {
match platform {
Platform::CurseForge => entry.curseforge_slug.as_deref(),
Platform::Modrinth => entry.modrinth_slug.as_deref(),
}
}
fn search_sources(
entry: &WikiEntry,
platform: Platform,
) -> Vec<(Vec<String>, f64)> {
let Some(slug) = platform_slug(entry, platform) else {
return Vec::new();
};
match &entry.chinese_name {
Some(chinese_name) => {
let primary = chinese_name
.split_once(" (")
.map_or(chinese_name.as_str(), |(name, _)| name);
vec![
(
primary
.split('/')
.map(|alias| zhconv(alias, Variant::ZhCN))
.collect(),
1.0,
),
(vec![format!("{chinese_name}{slug}")], 0.5),
]
}
None => vec![(vec![slug.to_string()], 0.5)],
}
}
fn search_entries<'a>(
entries: &'a [WikiEntry],
query: &str,
platform: Platform,
) -> Vec<SearchMatch<'a>> {
let query_parts = query.split_whitespace().collect::<Vec<_>>();
let mut candidates = entries
.iter()
.filter(|entry| platform_slug(entry, platform).is_some())
.filter_map(|entry| {
let sources = search_sources(entry, platform);
let similarity = weighted_similarity(&sources, query);
let exact = query_parts.iter().all(|query_part| {
let query_part = query_part.to_lowercase();
sources.iter().any(|(aliases, _)| {
aliases.iter().any(|alias| {
alias
.replace(' ', "")
.to_lowercase()
.contains(&query_part)
})
})
});
(exact || similarity >= MIN_SIMILARITY).then_some(SearchMatch {
entry,
similarity,
exact,
})
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
right
.exact
.cmp(&left.exact)
.then_with(|| right.similarity.total_cmp(&left.similarity))
.then_with(|| right.entry.popularity.cmp(&left.entry.popularity))
.then_with(|| {
platform_slug(left.entry, platform)
.cmp(&platform_slug(right.entry, platform))
})
});
let mut fuzzy_count = 0;
candidates
.into_iter()
.filter(|candidate| {
if candidate.exact {
true
} else if fuzzy_count < MAX_MATCHES {
fuzzy_count += 1;
true
} else {
false
}
})
.collect()
}
fn weighted_similarity(sources: &[(Vec<String>, f64)], query: &str) -> f64 {
let total_weight = sources.iter().map(|(_, weight)| weight).sum::<f64>();
if total_weight == 0.0 {
return 0.0;
}
sources
.iter()
.map(|(aliases, weight)| {
aliases
.iter()
.map(|alias| search_similarity(alias, query))
.fold(0.0, f64::max)
* weight
})
.sum::<f64>()
/ total_weight
}
fn search_similarity(source: &str, query: &str) -> f64 {
if source.is_empty() || query.is_empty() {
return 0.0;
}
let mut source_chars = source
.to_lowercase()
.chars()
.filter(|character| *character != ' ')
.collect::<Vec<_>>();
let query_chars = query
.to_lowercase()
.chars()
.filter(|character| *character != ' ')
.collect::<Vec<_>>();
let source_length = source_chars.len();
let query_length = query_chars.len();
if query_length == 0 {
return 0.0;
}
let mut query_position = 0;
let mut length_sum = 0.0;
while query_position < query_length {
let mut source_position = 0;
let mut max_length = 0;
let mut max_position = 0;
while source_position < source_chars.len() {
let mut length = 0;
while query_position + length < query_length
&& source_position + length < source_chars.len()
&& source_chars[source_position + length]
== query_chars[query_position + length]
{
length += 1;
}
if length > max_length {
max_length = length;
max_position = source_position;
}
source_position += if length > 0 { length } else { 1 };
}
if max_length > 0 {
source_chars.drain(max_position..max_position + max_length);
let mut weight = 1.4_f64.powi(3 + max_length as i32) - 3.6;
let distance = query_position.abs_diff(max_position) as f64;
weight *= 1.0 + 0.3 * (3.0 - distance).max(0.0);
length_sum += weight;
}
query_position += if max_length > 0 { max_length } else { 1 };
}
(length_sum / query_length as f64)
* (3.0 / (source_length as f64 + 15.0).sqrt())
* if query_length <= 2 {
(3 - query_length) as f64
} else {
1.0
}
}
fn select_curseforge_query(matches: &[SearchMatch<'_>]) -> Option<String> {
let first = matches.first()?;
let eligible = if first.exact {
matches
.iter()
.filter(|result| result.exact)
.collect::<Vec<_>>()
} else {
matches
.iter()
.filter(|result| result.similarity == first.similarity)
.collect::<Vec<_>>()
};
let target = eligible.into_iter().max_by(|left, right| {
left.entry
.popularity
.cmp(&right.entry.popularity)
.then_with(|| {
left.entry
.curseforge_slug
.cmp(&right.entry.curseforge_slug)
.reverse()
})
})?;
let words = extract_words(target.entry, Platform::CurseForge);
(!words.is_empty()).then(|| words.join(" "))
}
fn select_modrinth_query(
matches: &[SearchMatch<'_>],
query: &str,
) -> Option<String> {
let mut word_weights = HashMap::<String, f64>::new();
for result in matches {
let sources = search_sources(result.entry, Platform::Modrinth);
let exact_alias = sources.iter().any(|(aliases, _)| {
aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(query))
});
let similarity = if exact_alias {
1000.0
} else {
result.similarity
};
for word in extract_words(result.entry, Platform::Modrinth) {
*word_weights.entry(word).or_default() +=
similarity * result.entry.popularity as f64;
}
}
word_weights
.into_iter()
.max_by(|(left_word, left_weight), (right_word, right_weight)| {
left_weight
.total_cmp(right_weight)
.then_with(|| right_word.cmp(left_word))
})
.map(|(word, _)| word)
}
fn extract_words(entry: &WikiEntry, platform: Platform) -> Vec<String> {
let mut candidates = Vec::new();
if let Some(slug) = platform_slug(entry, platform) {
candidates.push(slug.replace(['-', '/'], " "));
}
if let Some(chinese_name) = &entry.chinese_name {
let parenthetical = chinese_name
.rsplit_once(" (")
.map_or(chinese_name.as_str(), |(_, suffix)| suffix)
.trim_end_matches([')', ' ']);
candidates.push(
parenthetical
.split_once(" - ")
.map_or(parenthetical, |(prefix, _)| prefix)
.replace(['-', '/', ':', '(', ')'], " "),
);
}
let stop_words = [
"the", "of", "mod", "and", "forge", "fabric", "for", "quilt",
"neoforge",
];
let mut seen = HashSet::new();
let mut words = candidates
.iter()
.flat_map(|candidate| candidate.split_whitespace())
.map(|word| {
word.trim_start_matches(['{', '[', '('])
.trim_end_matches(['}', ']', ')'])
.to_lowercase()
})
.filter(|word| {
word.len() > 1
&& !stop_words.contains(&word.as_str())
&& !word.chars().all(|character| character.is_ascii_digit())
&& word.is_ascii()
})
.filter(|word| seen.insert(word.clone()))
.collect::<Vec<_>>();
let all_words = words.clone();
words.retain(|word| {
!all_words.iter().any(|prefix| {
prefix.len() < word.len()
&& word.starts_with(prefix)
&& can_form_from_words(&word[prefix.len()..], &all_words)
})
});
words
}
fn can_form_from_words(value: &str, words: &[String]) -> bool {
value.is_empty()
|| words.iter().any(|word| {
value == word
|| value.strip_prefix(word).is_some_and(|remainder| {
can_form_from_words(remainder, words)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_platform_slugs_aliases_and_star_names() {
let entries =
parse_wiki_entries("curse@modrinth|中文*¨@only-mr|别名/又名\n001");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].curseforge_slug.as_deref(), Some("curse"));
assert_eq!(entries[0].modrinth_slug.as_deref(), Some("modrinth"));
assert_eq!(entries[0].chinese_name.as_deref(), Some("中文 (Curse)"));
assert_eq!(entries[0].popularity, 1);
assert_eq!(entries[0].wiki_id, 1);
assert_eq!(entries[1].curseforge_slug, None);
assert_eq!(entries[1].modrinth_slug.as_deref(), Some("only-mr"));
assert_eq!(entries[1].wiki_id, 1);
}
#[test]
fn empty_lines_reserve_wiki_ids_and_keep_popularity_aligned() {
let entries = parse_wiki_entries("first@|甲\n\nthird@|乙\n001002");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].wiki_id, 1);
assert_eq!(entries[0].popularity, 1);
assert_eq!(entries[1].wiki_id, 3);
assert_eq!(entries[1].popularity, 2);
}
#[test]
fn decodes_base_86_popularity() {
assert_eq!(decode_popularity(b"001"), 1);
assert_eq!(decode_popularity(b"010"), 86);
}
#[test]
fn resolves_simplified_and_traditional_queries() {
let simplified = resolve_chinese_content_search("应用能源2");
let traditional = resolve_chinese_content_search("應用能源2");
let twilight_forest = resolve_chinese_content_search("暮色森林");
assert!(simplified.is_chinese);
assert_eq!(traditional.normalized_query, simplified.normalized_query);
assert!(simplified.modrinth_slugs.iter().any(|slug| slug == "ae2"));
assert!(
simplified
.translations
.iter()
.any(|translation| translation.curseforge_slug.as_deref()
== Some("applied-energistics-2"))
);
assert!(
twilight_forest
.translations
.iter()
.any(|translation| translation.curseforge_slug.as_deref()
== Some("the-twilight-forest"))
);
assert_eq!(
twilight_forest.curseforge_query.as_deref(),
Some("twilight forest")
);
}
#[test]
fn leaves_non_chinese_and_unknown_queries_without_alternatives() {
let english = resolve_chinese_content_search("Applied Energistics");
assert!(!english.is_chinese);
assert!(english.modrinth_query.is_none());
let unknown =
resolve_chinese_content_search("龘靐齉齾爩鱻麤龗灪爨癵籱");
assert!(unknown.is_chinese);
assert!(unknown.curseforge_query.is_none());
assert!(unknown.modrinth_query.is_none());
}
#[test]
fn looks_up_chinese_names_for_known_slugs() {
let lookup = lookup_chinese_content_names(
&["ae2".to_string(), "totally-unknown-project".to_string()],
&["the-twilight-forest".to_string()],
);
assert_eq!(
lookup.modrinth.get("ae2").map(String::as_str),
Some("应用能源2 (Applied Energistics 2)")
);
assert!(!lookup.modrinth.contains_key("totally-unknown-project"));
assert_eq!(
lookup
.curseforge
.get("the-twilight-forest")
.map(String::as_str),
Some("暮色森林 (The Twilight Forest)")
);
}
#[test]
fn looks_up_chinese_names_case_insensitively_keyed_by_input() {
let lookup = lookup_chinese_content_names(&["AE2".to_string()], &[]);
assert_eq!(
lookup.modrinth.get("AE2").map(String::as_str),
Some("应用能源2 (Applied Energistics 2)")
);
}
#[test]
fn looks_up_wiki_ids_for_known_slugs() {
let lookup = lookup_content_wiki_ids(
&[
"industrial-craft".to_string(),
"totally-unknown-project".to_string(),
],
&["Industrial-Craft".to_string()],
);
assert_eq!(lookup.modrinth.get("industrial-craft"), Some(&2));
assert!(!lookup.modrinth.contains_key("totally-unknown-project"));
assert_eq!(lookup.curseforge.get("Industrial-Craft"), Some(&2));
}
#[test]
fn looks_up_cross_platform_content_identities() {
let lookup = lookup_content_identities(
&["ae2".to_string(), "unknown".to_string()],
&["applied-energistics-2".to_string()],
);
let modrinth = lookup.modrinth.get("ae2").expect("Modrinth identity");
let curseforge = lookup
.curseforge
.get("applied-energistics-2")
.expect("CurseForge identity");
assert_eq!(modrinth.len(), 1);
assert_eq!(modrinth[0].key, curseforge[0].key);
assert!(
modrinth[0]
.counterparts
.iter()
.any(|counterpart| counterpart.provider == "curseforge")
);
assert!(!lookup.modrinth.contains_key("unknown"));
}
#[test]
fn cross_platform_identity_lookup_requires_both_slugs() {
let lookup = lookup_content_identities(
&["only-mr".to_string()],
&["not-present".to_string()],
);
assert!(lookup.modrinth.is_empty());
assert!(lookup.curseforge.is_empty());
}
#[test]
fn wiki_id_index_prefers_more_popular_entries_for_duplicate_slugs() {
let entries = parse_wiki_entries("dup@|旧名\ndup@|新名\n001002");
let index = build_wiki_id_index(&entries);
assert_eq!(index.modrinth.get("dup"), Some(&2));
assert_eq!(index.curseforge.get("dup"), Some(&2));
}
#[test]
fn prefers_more_popular_entries_for_duplicate_slugs() {
let entries = parse_wiki_entries("dup@|旧名\ndup@|新名\n001002");
let index = build_chinese_name_index(&entries);
assert_eq!(index.modrinth.get("dup").map(String::as_str), Some("新名"));
assert_eq!(
index.curseforge.get("dup").map(String::as_str),
Some("新名")
);
}
#[test]
fn skips_entries_without_chinese_names_in_index() {
let entries = parse_wiki_entries("no-name@\n001");
let index = build_chinese_name_index(&entries);
assert!(index.modrinth.is_empty());
assert!(index.curseforge.is_empty());
}
#[test]
fn file_titles_strip_english_aliases() {
assert_eq!(
chinese_file_title_for_modrinth_slug("ae2").as_deref(),
Some("应用能源2")
);
assert_eq!(
chinese_file_title_for_curseforge_slug("the-twilight-forest")
.as_deref(),
Some("暮色森林")
);
assert_eq!(chinese_file_title_for_modrinth_slug("unknown-slug"), None);
}
#[test]
fn localizes_content_file_names() {
assert_eq!(
localized_content_file_name("sodium-0.5.8.jar", "").as_deref(),
Some("[钠]sodium-0.5.8.jar")
);
assert_eq!(localized_content_file_name("[1.19]mod.jar", ""), None);
assert_eq!(localized_content_file_name("mod.jar", "Sodium"), None);
assert_eq!(localized_content_file_name("mod.jar", "钠]x"), None);
assert_eq!(localized_content_file_name("", ""), None);
let oversized_title = "".repeat(80);
assert_eq!(
localized_content_file_name("mod.jar", &oversized_title),
None
);
}
#[test]
fn recovers_original_content_file_names() {
assert_eq!(
original_content_file_name("[钠]sodium-0.5.8.jar"),
"sodium-0.5.8.jar"
);
assert_eq!(
original_content_file_name("[1.19]mod.jar"),
"[1.19]mod.jar"
);
assert_eq!(original_content_file_name("[钠]"), "[钠]");
assert_eq!(original_content_file_name("plain.jar"), "plain.jar");
}
#[test]
fn localizes_and_recovers_relative_paths() {
assert_eq!(
localized_content_relative_path("mods/sodium-0.5.8.jar", "")
.as_deref(),
Some("mods/[钠]sodium-0.5.8.jar")
);
assert_eq!(
original_content_relative_path("mods/[钠]sodium-0.5.8.jar"),
"mods/sodium-0.5.8.jar"
);
assert_eq!(
original_content_relative_path(
"saves/world/datapacks/[钠]pack.zip"
),
"saves/world/datapacks/pack.zip"
);
assert_eq!(
original_content_relative_path("mods/plain.jar"),
"mods/plain.jar"
);
}
#[test]
fn removes_words_that_can_be_composed_from_shorter_words() {
let entry = WikiEntry {
wiki_id: 1,
chinese_name: Some("末影接口 (Ender IO EnderIO)".to_string()),
curseforge_slug: Some("ender-io-enderio".to_string()),
modrinth_slug: None,
popularity: 1,
};
let words = extract_words(&entry, Platform::CurseForge);
assert!(words.contains(&"ender".to_string()));
assert!(words.contains(&"io".to_string()));
assert!(!words.contains(&"enderio".to_string()));
}
#[test]
fn segments_compact_queries_into_known_words() {
assert_eq!(
expand_content_search_query("sodiumextra")
.suggested_split
.as_deref(),
Some("sodium extra")
);
assert_eq!(
expand_content_search_query("irisshaders")
.suggested_split
.as_deref(),
Some("iris shaders")
);
assert_eq!(
expand_content_search_query("examplemod")
.suggested_split
.as_deref(),
Some("example mod")
);
assert_eq!(
expand_content_search_query("thetwilightforest")
.suggested_split
.as_deref(),
Some("the twilight forest")
);
assert_eq!(
expand_content_search_query("shulkerbox")
.suggested_split
.as_deref(),
Some("shulker box")
);
}
#[test]
fn leaves_known_and_unsegmentable_queries_without_a_split() {
assert_eq!(expand_content_search_query("sodium").suggested_split, None);
assert_eq!(expand_content_search_query("ae2").suggested_split, None);
assert_eq!(expand_content_search_query("xyzabc").suggested_split, None);
assert_eq!(expand_content_search_query("").suggested_split, None);
assert_eq!(expand_content_search_query("a b").suggested_split, None);
}
#[test]
fn segmentation_ignores_separators_and_case() {
assert_eq!(
expand_content_search_query("Sodium-Extra")
.suggested_split
.as_deref(),
Some("sodium extra")
);
assert_eq!(
expand_content_search_query("sodium extra")
.suggested_split
.as_deref(),
Some("sodium extra")
);
}
}