const BIBLIOGRAPHIC: &[(&str, &str)] = &[
("alb", "sq"),
("arm", "hy"),
("baq", "eu"),
("bur", "my"),
("chi", "zh"),
("cze", "cs"),
("dut", "nl"),
("fre", "fr"),
("geo", "ka"),
("ger", "de"),
("gre", "el"),
("ice", "is"),
("mac", "mk"),
("mao", "mi"),
("may", "ms"),
("per", "fa"),
("rum", "ro"),
("slo", "sk"),
("tib", "bo"),
("wel", "cy"),
];
const GERMAN: &[(&str, &str)] = &[
("af", "Afrikaans"),
("ar", "Arabisch"),
("be", "Belarussisch"),
("bg", "Bulgarisch"),
("bn", "Bengalisch"),
("bs", "Bosnisch"),
("ca", "Katalanisch"),
("cs", "Tschechisch"),
("cy", "Walisisch"),
("da", "Dänisch"),
("de", "Deutsch"),
("el", "Griechisch"),
("en", "Englisch"),
("eo", "Esperanto"),
("es", "Spanisch"),
("et", "Estnisch"),
("eu", "Baskisch"),
("fa", "Persisch"),
("fi", "Finnisch"),
("fr", "Französisch"),
("ga", "Irisch"),
("gd", "Schottisch-Gälisch"),
("gl", "Galicisch"),
("he", "Hebräisch"),
("hi", "Hindi"),
("hr", "Kroatisch"),
("hu", "Ungarisch"),
("hy", "Armenisch"),
("id", "Indonesisch"),
("is", "Isländisch"),
("it", "Italienisch"),
("ja", "Japanisch"),
("ka", "Georgisch"),
("ko", "Koreanisch"),
("la", "Latein"),
("lb", "Luxemburgisch"),
("lt", "Litauisch"),
("lv", "Lettisch"),
("mk", "Mazedonisch"),
("ms", "Malaiisch"),
("mt", "Maltesisch"),
("nb", "Norwegisch (Bokmål)"),
("nl", "Niederländisch"),
("nn", "Norwegisch (Nynorsk)"),
("no", "Norwegisch"),
("pl", "Polnisch"),
("pt", "Portugiesisch"),
("ro", "Rumänisch"),
("ru", "Russisch"),
("sk", "Slowakisch"),
("sl", "Slowenisch"),
("sq", "Albanisch"),
("sr", "Serbisch"),
("sv", "Schwedisch"),
("sw", "Suaheli"),
("th", "Thailändisch"),
("tr", "Türkisch"),
("uk", "Ukrainisch"),
("ur", "Urdu"),
("vi", "Vietnamesisch"),
("zh", "Chinesisch"),
];
const ALIASES: &[(&str, &str)] = &[
("american english", "en"),
("british english", "en"),
("cantonese", "zh"),
("castellano", "es"),
("castilian", "es"),
("chichewa", "ny"),
("cmn", "zh"),
("divehi", "dv"),
("dt", "de"),
("dtsch", "de"),
("engl", "en"),
("farsi", "fa"),
("flemish", "nl"),
("galizisch", "gl"),
("greek", "el"),
("haitian creole", "ht"),
("in", "id"),
("iw", "he"),
("ji", "yi"),
("jw", "jv"),
("kyrgyz", "ky"),
("lateinisch", "la"),
("mandarin", "zh"),
("mo", "ro"),
("moldovan", "ro"),
("neugriechisch", "el"),
("pashto", "ps"),
("punjabi", "pa"),
("sinhalese", "si"),
("slovene", "sl"),
("uyghur", "ug"),
("valencian", "ca"),
("weißrussisch", "be"),
("yue", "zh"),
];
const ENGLISH: &[(&str, &str)] = &[
("dv", "Divehi"),
("el", "Greek"),
("ht", "Haitian Creole"),
("ky", "Kyrgyz"),
("nb", "Norwegian Bokmål"),
("nn", "Norwegian Nynorsk"),
("ny", "Nyanja"),
("pa", "Punjabi"),
("ps", "Pashto"),
("si", "Sinhala"),
("ug", "Uyghur"),
];
static AUTONYMS: std::sync::LazyLock<std::collections::HashMap<String, isolang::Language>> =
std::sync::LazyLock::new(|| {
isolang::languages()
.filter_map(|l| Some((l.to_autonym()?.to_lowercase(), l)))
.collect()
});
pub fn language(text: &str) -> Option<String> {
text.split([';', ',', '/'])
.find_map(one_language)
.map(str::to_string)
}
fn one_language(text: &str) -> Option<&'static str> {
use isolang::Language;
let text = text.split('(').next().unwrap_or(text);
let text = text.trim().trim_end_matches('.').trim();
if text.is_empty() {
return None;
}
let lower = text.to_lowercase();
if let Some((_, code)) = ALIASES.iter().find(|(alias, _)| *alias == lower) {
return Some(code);
}
let primary = lower.split(['-', '_']).next().unwrap_or(&lower);
let code = |l: Language| l.to_639_1();
if let Some((_, code)) = ALIASES.iter().find(|(alias, _)| *alias == primary) {
return Some(code);
}
let found = match primary.len() {
2 => Language::from_639_1(primary).and_then(code),
3 => BIBLIOGRAPHIC
.iter()
.find(|(b, _)| *b == primary)
.map(|(_, c)| *c)
.or_else(|| Language::from_639_3(primary).and_then(code)),
_ => None,
};
found
.or_else(|| Language::from_name(text).and_then(code))
.or_else(|| Language::from_name_lowercase(&lower).and_then(code))
.or_else(|| {
GERMAN
.iter()
.find(|(_, name)| name.to_lowercase() == lower)
.map(|(c, _)| *c)
})
.or_else(|| AUTONYMS.get(&lower).copied().and_then(code))
}
pub fn language_name(code: &str, german: bool) -> String {
if german && let Some((_, name)) = GERMAN.iter().find(|(c, _)| *c == code) {
return name.to_string();
}
if let Some((_, name)) = ENGLISH.iter().find(|(c, _)| *c == code) {
return name.to_string();
}
isolang::Language::from_639_1(code)
.map(|l| l.to_name().to_string())
.unwrap_or_else(|| code.to_string())
}
const FILLER: &[&str] = &[
"license",
"licence",
"lizenz",
"international",
"unported",
"universal",
"deed",
"generic",
];
pub fn license(text: &str) -> Option<String> {
let text = text.trim();
if text.is_empty() {
return None;
}
let id = |candidate: &str| commonmeta::spdx::search(candidate).map(|l| l.license_id.clone());
if let Some(found) = id(text).or_else(|| cc_url(text).and_then(|c| id(&c))) {
return Some(found);
}
let mut candidates = vec![text.split('(').next().unwrap_or(text).to_string()];
if let Some((_, inner)) = text.split_once('(') {
candidates.push(inner.trim_end_matches(')').to_string());
}
for candidate in &candidates {
let words: Vec<String> = candidate
.to_uppercase()
.split(|c: char| c.is_whitespace() || c == '_')
.map(|w| w.trim_matches(',').to_string())
.filter(|w| !w.is_empty() && !FILLER.contains(&w.to_lowercase().as_str()))
.collect();
let mut normalized = words.join("-");
if normalized == "CC0" || normalized == "CC-ZERO" {
normalized = "CC0-1.0".to_string();
}
if let Some(found) = id(&normalized) {
return Some(found);
}
if let Some((_, found)) = LICENSE_NAMES
.iter()
.find(|(name, _)| *name == name_key(candidate))
{
return Some(found.clone());
}
}
None
}
fn name_key(name: &str) -> String {
let lower = name
.to_lowercase()
.replace("sharealike", "share alike")
.replace("noncommercial", "non commercial")
.replace("noderivatives", "no derivatives")
.replace("noderivs", "no derivatives");
lower
.split(|c: char| !c.is_alphanumeric() && c != '.')
.map(|w| w.trim_matches('.'))
.filter(|w| !w.is_empty() && !FILLER.contains(w))
.collect::<Vec<_>>()
.join("")
}
fn cc_url(text: &str) -> Option<String> {
let lower = text.to_lowercase();
let (_, path) = lower.split_once("creativecommons.org/")?;
let path = path.split(['?', '#']).next().unwrap_or(path);
let parts: Vec<&str> = path
.split('/')
.filter(|p| !p.is_empty() && !p.starts_with("deed") && !p.starts_with("legalcode"))
.collect();
match parts.as_slice() {
["licenses", kind, version, rest @ ..] => {
let mut id = format!("CC-{}-{version}", kind.to_uppercase());
if let [jurisdiction] = rest {
id = format!("{id}-{}", jurisdiction.to_uppercase());
}
Some(id)
}
["publicdomain", "zero", version, ..] => Some(format!("CC0-{version}")),
_ => None,
}
}
static LICENSE_NAMES: std::sync::LazyLock<Vec<(String, String)>> = std::sync::LazyLock::new(|| {
let list: serde_json::Value = commonmeta::vocabularies::load_vocabulary("SPDX.Licenses")
.ok()
.and_then(|raw| serde_json::from_str(raw).ok())
.unwrap_or_default();
list["licenses"]
.as_array()
.into_iter()
.flatten()
.filter(|l| l["isDeprecatedLicenseId"] != serde_json::Value::Bool(true))
.filter_map(|l| {
Some((
name_key(l["name"].as_str()?),
l["licenseId"].as_str()?.to_string(),
))
})
.collect()
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_languages_as_zotero_holds_them() {
for (text, code) in [
("en", "en"),
("EN", "en"),
("en-US", "en"),
("en_GB", "en"),
("eng", "en"),
("ger", "de"),
("deu", "de"),
("English", "en"),
("english", "en"),
("Englisch", "en"),
("Deutsch", "de"),
("français", "fr"),
(" French ", "fr"),
] {
assert_eq!(language(text).as_deref(), Some(code), "{text}");
}
for text in ["", " ", "xx", "Klingon", "zzz", "multiple"] {
assert_eq!(language(text), None, "{text}");
}
for (text, code) in [
("Greek", "el"),
("Farsi", "fa"),
("Punjabi", "pa"),
("Slovene", "sl"),
("English (US)", "en"),
("Portuguese (Brazil)", "pt"),
("Engl.", "en"),
("iw", "he"),
("cmn", "zh"),
("Mandarin", "zh"),
("en; de", "en"),
("English, German", "en"),
("Greek, Modern (1453-)", "el"),
("Luxemburgisch", "lb"),
] {
assert_eq!(language(text).as_deref(), Some(code), "{text}");
}
assert_eq!(language_name("el", false), "Greek");
assert_eq!(language_name("de", false), "German");
assert_eq!(language_name("de", true), "Deutsch");
assert_eq!(language_name("ng", true), "Ndonga");
}
#[test]
fn reads_licenses_as_zotero_holds_them() {
for (text, id) in [
("CC-BY-4.0", "CC-BY-4.0"),
("cc-by-4.0", "CC-BY-4.0"),
("CC BY 4.0", "CC-BY-4.0"),
("CC BY-NC-SA 4.0", "CC-BY-NC-SA-4.0"),
("https://creativecommons.org/licenses/by/4.0/", "CC-BY-4.0"),
(
"http://creativecommons.org/licenses/by-nc/4.0",
"CC-BY-NC-4.0",
),
("CC0", "CC0-1.0"),
("CC0 1.0", "CC0-1.0"),
("MIT", "MIT"),
("MIT License", "MIT"),
("Apache 2.0", "Apache-2.0"),
(
"Creative Commons Attribution 4.0 International",
"CC-BY-4.0",
),
(
"Creative Commons Attribution 4.0 International License",
"CC-BY-4.0",
),
(
"Creative Commons Attribution-ShareAlike 4.0 International",
"CC-BY-SA-4.0",
),
(
"Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International",
"CC-BY-NC-ND-4.0",
),
("Attribution 4.0 International (CC BY 4.0)", "CC-BY-4.0"),
("CC BY 4.0 International", "CC-BY-4.0"),
("CC BY-NC-ND 4.0 International", "CC-BY-NC-ND-4.0"),
("CC BY 3.0 Unported", "CC-BY-3.0"),
("CC0 1.0 Universal", "CC0-1.0"),
("MIT Licence", "MIT"),
(
"https://creativecommons.org/licenses/by/4.0/deed.en",
"CC-BY-4.0",
),
(
"https://creativecommons.org/licenses/by-sa/4.0/legalcode.de",
"CC-BY-SA-4.0",
),
(
"creativecommons.org/licenses/by-nc-nd/4.0",
"CC-BY-NC-ND-4.0",
),
(
"https://creativecommons.org/licenses/by/3.0/de/",
"CC-BY-3.0-DE",
),
(
"https://creativecommons.org/publicdomain/zero/1.0/",
"CC0-1.0",
),
] {
assert_eq!(license(text).as_deref(), Some(id), "{text}");
}
for text in ["", "All rights reserved", "© 2024 The Authors", "CC BY"] {
assert_eq!(license(text), None, "{text}");
}
}
}