Validation

Research identifiers

The identifiers of research: DOIs cleaned from links and citations, ORCID iDs and ISNIs checked, ISSNs checked with their barcode, arXiv ids read with their version, PubMed ids recognized, and all of them found in a reference list.

Version 1.0.0 · 6 functions · 2 crates

#validation#links#books#search

Functions

What use scholarly_ids.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use doi_parse, orcid_check from scholarly_ids (Quillo 0.6).

doi_parse(text: str) → any

A DOI cleaned from however it was written (doi:10..., https://dx.doi.org/10..., with trailing punctuation from a sentence) into { valid, doi, prefix, suffix, url, error }; doi is lower case, as DOIs compare without case.

orcid_check(text: str, isni: bool) → any

An ORCID iD (0000-0002-1825-0097, or its https://orcid.org/ URL) checked: { valid, orcid, url, error }. ISNIs use the same check digit, so isni true checks one of those (16 digits) instead.

issn_check(text: str) → any

An ISSN checked: { valid, issn, ean13, error }, ean13 being the barcode a journal issue prints (977, the ISSN without its check digit, 00, and the EAN check digit).

arxiv_parse(text: str) → any

An arXiv identifier read, new style (2310.06825v2, since 2007) or old (hep-th/9901001), from the id, arXiv: form or URL: { valid, id, version, year, month, archive, url, pdf, error }.

scholar_id(text: str) → any

What kind of research identifier text is, and its clean form: { type, id, url, valid }, type being "doi", "orcid", "issn", "arxiv", "pmid", "pmcid" or null.

scholar_find(text: str) → list

Every DOI and arXiv id in a text (a bibliography, a paper's references), in the order found, each once: [{ type, id, url }].

Get it

$ quillo install scholarly_ids               # scholarly_ids.qwer into this folder$ quillo install scholarly_ids --example     # and its example, scholarly_ids.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/scholarly_ids.qwer$ curl -O https://quillolang.org/plugins/files/scholarly_ids.qwe

Then, in your program, all of it or only what you call:

main.qwe
use scholarly_ids.qweruse doi_parse, orcid_check from scholarly_ids      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
regex = "1"serde_json = { version = "1", features = ["preserve_order"] }

Example

scholarly_ids.qwe
// A reading-list app for a lab: identifiers pasted in any form turned into links, an author's// ORCID checked at sign-up, and the references of a paper pulled out of its text.////   quillo run scholarly_ids.qwe      then open http://localhost:8080/ids and http://localhost:8080/referencesuse "scholarly_ids.qwer"route GET /ids = {    doi: doi_parse("see https://dx.doi.org/10.1038/NPHYS1170."),    orcid: orcid_check("https://orcid.org/0000-0002-1825-0097", false),    orcid_typo: orcid_check("0000-0002-1825-0098", false).error,    isni: orcid_check("0000 0001 2281 955X", true).valid,    issn: issn_check("ISSN 0378-5955"),    arxiv: arxiv_parse("arXiv:1706.03762v7"),    arxiv_old: arxiv_parse("hep-th/9711200").url,    guesses: ["10.1145/3290605.3300857", "PMC6385936", "PMID: 31452104", "2049-3630", "astro-ph/0001001"] | map((x) => scholar_id(x).type),}let PAPER = "References\n[1] Vaswani et al. Attention is all you need. arXiv:1706.03762 (2017).\n[2] Devlin et al. BERT (doi:10.18653/v1/N19-1423).\n[3] Maldacena, hep-th/9711200.\n[4] Same as [1]: https://arxiv.org/abs/1706.03762\n[5] https://doi.org/10.1016/S0140-6736(20)30183-5."route GET /references = scholar_find(PAPER)
$ quillo run scholarly_ids.qwe$ curl 'localhost:8080/ids'$ curl 'localhost:8080/references'

Source

scholarly_ids.qwer
//! The identifiers of research: DOIs cleaned from links and citations into one form with their URL, ORCID iDs and ISNIs checked, ISSNs checked and turned into their barcode, arXiv ids (old and new) read with their version, PubMed ids recognized, and every one of them found in a reference list.//! version: 1.0.0//!//! [dependencies]//! regex = "1"//! serde_json = { version = "1", features = ["preserve_order"] }use regex::Regex;use serde_json::{json, Value};use std::sync::OnceLock;fn re(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {    cell.get_or_init(|| Regex::new(pattern).expect("a valid pattern"))}static DOI: OnceLock<Regex> = OnceLock::new();static ARXIV_NEW: OnceLock<Regex> = OnceLock::new();static ARXIV_OLD: OnceLock<Regex> = OnceLock::new();const DOI_RE: &str = r"(?i)\b(10\.\d{4,9}/[^\s\x22<>]+)";const ARXIV_NEW_RE: &str = r"(?i)(?:arxiv:|arxiv\.org/(?:abs|pdf)/)?\b(\d{2})(0[1-9]|1[0-2])\.(\d{4,5})(v\d+)?\b";const ARXIV_OLD_RE: &str = r"(?i)(?:arxiv:|arxiv\.org/(?:abs|pdf)/)?\b([a-z-]+(?:\.[A-Z]{2})?)/(\d{2})(0[1-9]|1[0-2])(\d{3})(v\d+)?\b";fn trim_doi(s: &str) -> String {    let mut d = s.trim_end_matches(['.', ',', ';', ':', '\'', '"']).to_string();    // A closing bracket that was not opened inside the DOI belongs to the text around it.    while (d.ends_with(')') && d.matches('(').count() < d.matches(')').count()) || (d.ends_with(']') && d.matches('[').count() < d.matches(']').count()) {        d.pop();        d = d.trim_end_matches(['.', ',', ';']).to_string();    }    d}fn doi_url(doi: &str) -> String {    let enc: String = doi.chars().map(|c| match c { '%' => "%25".into(), '"' => "%22".into(), '#' => "%23".into(), ' ' => "%20".into(), '?' => "%3F".into(), c => c.to_string() }).collect();    format!("https://doi.org/{}", enc)}/// A DOI cleaned from however it was written (`doi:10...`, `https://dx.doi.org/10...`, with trailing punctuation from a sentence)/// into `{ valid, doi, prefix, suffix, url, error }`; `doi` is lower case, as DOIs compare without case.pub fn doi_parse(text: &str) -> Value {    let t = text.trim();    let t = percent_decode(t);    match re(&DOI, DOI_RE).captures(&t) {        Some(c) => {            let d = trim_doi(&c[1]).to_lowercase();            let (p, s) = d.split_once('/').unwrap_or((&d, ""));            if s.is_empty() {                return json!({ "valid": false, "error": "a DOI has a suffix after the slash" });            }            json!({ "valid": true, "doi": d, "prefix": p, "suffix": s, "url": doi_url(&d), "error": null })        }        None => json!({ "valid": false, "error": "no DOI (10.<registrant>/<suffix>) in the text" }),    }}fn percent_decode(s: &str) -> String {    let b = s.as_bytes();    let mut out = Vec::with_capacity(b.len());    let mut i = 0;    while i < b.len() {        if b[i] == b'%' && i + 2 < b.len() {            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {                out.push(v);                i += 3;                continue;            }        }        out.push(b[i]);        i += 1;    }    String::from_utf8_lossy(&out).to_string()}fn iso7064_11_2(body: &str) -> char {    let mut t: u32 = 0;    for c in body.chars() {        t = (t + c.to_digit(10).unwrap_or(0)) * 2;    }    match (12 - t % 11) % 11 { 10 => 'X', r => char::from_digit(r, 10).unwrap() }}/// An ORCID iD (`0000-0002-1825-0097`, or its `https://orcid.org/` URL) checked: `{ valid, orcid, url, error }`. ISNIs use the same/// check digit, so `isni` true checks one of those (16 digits) instead.pub fn orcid_check(text: &str, isni: bool) -> Value {    let s: String = text.trim().trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("orcid.org/").trim_start_matches("isni.org/isni/").chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect();    let what = if isni { "an ISNI" } else { "an ORCID iD" };    if s.len() != 16 || !s[..15].chars().all(|c| c.is_ascii_digit()) {        return json!({ "valid": false, "error": format!("{} has 16 digits (the last may be X)", what) });    }    if iso7064_11_2(&s[..15]) != s.chars().last().unwrap() {        return json!({ "valid": false, "error": "the check digit does not match: a typo" });    }    let formatted = format!("{}-{}-{}-{}", &s[..4], &s[4..8], &s[8..12], &s[12..]);    if isni {        return json!({ "valid": true, "isni": s, "formatted": formatted, "url": format!("https://isni.org/isni/{}", s), "error": null });    }    let n: u64 = s[..15].parse().unwrap_or(0);    if !((15_000_000..=35_000_000).contains(&n) || (900_000_000_000..=900_100_000_000).contains(&n)) {        return json!({ "valid": false, "error": "the number is outside the blocks ORCID gives out (0000-0001-5... to 0000-0003-5..., 0009-...)" });    }    json!({ "valid": true, "orcid": formatted, "url": format!("https://orcid.org/{}", formatted), "error": null })}/// An ISSN checked: `{ valid, issn, ean13, error }`, `ean13` being the barcode a journal issue prints (977, the ISSN without its/// check digit, `00`, and the EAN check digit).pub fn issn_check(text: &str) -> Value {    let s: String = text.trim().trim_start_matches("ISSN").trim_start_matches(':').chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect();    if s.len() != 8 || !s[..7].chars().all(|c| c.is_ascii_digit()) {        return json!({ "valid": false, "error": "an ISSN has 8 digits (the last may be X)" });    }    let t: u32 = s[..7].chars().zip((2..=8).rev()).map(|(c, w)| c.to_digit(10).unwrap() * w).sum();    let check = match (11 - t % 11) % 11 { 10 => 'X', r => char::from_digit(r, 10).unwrap() };    if check != s.chars().last().unwrap() {        return json!({ "valid": false, "error": "the check digit does not match: a typo" });    }    let body = format!("977{}00", &s[..7]);    let ean_check = (10 - body.chars().enumerate().map(|(i, c)| c.to_digit(10).unwrap() * if i % 2 == 0 { 1 } else { 3 }).sum::<u32>() % 10) % 10;    json!({ "valid": true, "issn": format!("{}-{}", &s[..4], &s[4..]), "ean13": format!("{}{}", body, ean_check), "error": null })}/// An arXiv identifier read, new style (`2310.06825v2`, since 2007) or old (`hep-th/9901001`), from the id, `arXiv:` form or URL:/// `{ valid, id, version, year, month, archive, url, pdf, error }`.pub fn arxiv_parse(text: &str) -> Value {    let t = text.trim();    if let Some(c) = re(&ARXIV_NEW, ARXIV_NEW_RE).captures(t) {        let yy: i32 = c[1].parse().unwrap_or(0);        if yy < 7 || (yy < 15 && c[3].len() == 5) || (yy >= 15 && c[3].len() == 4) {            return json!({ "valid": false, "error": "arXiv ids have 4 digits after the dot from 2007 to 2014, 5 since 2015" });        }        let id = format!("{}{}.{}", &c[1], &c[2], &c[3]);        let v = c.get(4).map(|v| v.as_str().to_string());        return json!({ "valid": true, "id": id, "version": v.as_ref().map(|v| v[1..].parse::<i64>().unwrap_or(1)), "year": 2000 + yy, "month": c[2].parse::<i64>().unwrap_or(0), "archive": null, "url": format!("https://arxiv.org/abs/{}{}", id, v.clone().unwrap_or_default()), "pdf": format!("https://arxiv.org/pdf/{}{}", id, v.unwrap_or_default()), "error": null });    }    if let Some(c) = re(&ARXIV_OLD, ARXIV_OLD_RE).captures(t) {        let yy: i32 = c[2].parse().unwrap_or(0);        let id = format!("{}/{}{}{}", c[1].to_lowercase(), &c[2], &c[3], &c[4]);        let v = c.get(5).map(|v| v.as_str().to_string());        return json!({ "valid": true, "id": id, "version": v.as_ref().map(|v| v[1..].parse::<i64>().unwrap_or(1)), "year": if yy >= 91 { 1900 + yy } else { 2000 + yy }, "month": c[3].parse::<i64>().unwrap_or(0), "archive": c[1].to_lowercase(), "url": format!("https://arxiv.org/abs/{}{}", id, v.clone().unwrap_or_default()), "pdf": format!("https://arxiv.org/pdf/{}{}", id, v.unwrap_or_default()), "error": null });    }    json!({ "valid": false, "error": "not an arXiv id (YYMM.NNNNN or archive/YYMMNNN)" })}/// What kind of research identifier `text` is, and its clean form: `{ type, id, url, valid }`, `type` being "doi", "orcid", "issn",/// "arxiv", "pmid", "pmcid" or null.pub fn scholar_id(text: &str) -> Value {    let t = text.trim();    let l = t.to_lowercase();    if l.contains("10.") && doi_parse(t)["valid"] == json!(true) {        let d = doi_parse(t);        return json!({ "type": "doi", "id": d["doi"], "url": d["url"], "valid": true });    }    if l.contains("orcid") || (t.len() == 19 && t.matches('-').count() == 3) {        let o = orcid_check(t, false);        return json!({ "type": "orcid", "id": o["orcid"], "url": o["url"], "valid": o["valid"] });    }    if l.contains("arxiv") || re(&ARXIV_NEW, ARXIV_NEW_RE).is_match(t) || re(&ARXIV_OLD, ARXIV_OLD_RE).is_match(t) {        let a = arxiv_parse(t);        return json!({ "type": "arxiv", "id": a["id"], "url": a["url"], "valid": a["valid"] });    }    let compact: String = t.chars().filter(|c| !c.is_whitespace()).collect();    if let Some(n) = compact.to_uppercase().strip_prefix("PMC").filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) {        return json!({ "type": "pmcid", "id": format!("PMC{}", n), "url": format!("https://pmc.ncbi.nlm.nih.gov/articles/PMC{}/", n), "valid": true });    }    let pm = l.trim_start_matches("pmid").trim_start_matches(':').trim();    if !pm.is_empty() && pm.len() <= 8 && pm.chars().all(|c| c.is_ascii_digit()) && (l.starts_with("pmid") || pm.len() >= 5) {        return json!({ "type": "pmid", "id": pm, "url": format!("https://pubmed.ncbi.nlm.nih.gov/{}/", pm), "valid": true });    }    if l.contains("issn") || (compact.len() == 9 && compact.as_bytes()[4] == b'-') {        let i = issn_check(t);        return json!({ "type": "issn", "id": i["issn"], "url": null, "valid": i["valid"] });    }    json!({ "type": null, "id": null, "url": null, "valid": false })}/// Every DOI and arXiv id in a text (a bibliography, a paper's references), in the order found, each once: `[{ type, id, url }]`.pub fn scholar_find(text: &str) -> Vec<Value> {    let mut found: Vec<(usize, Value)> = vec![];    for m in re(&DOI, DOI_RE).find_iter(text) {        let d = trim_doi(m.as_str()).to_lowercase();        found.push((m.start(), json!({ "type": "doi", "id": d, "url": doi_url(&d) })));    }    for r in [re(&ARXIV_NEW, ARXIV_NEW_RE), re(&ARXIV_OLD, ARXIV_OLD_RE)] {        for m in r.find_iter(text) {            // A DOI's suffix can look like an arXiv id; leave those to the DOI.            if found.iter().any(|(s, v)| *s <= m.start() && v["type"] == "doi" && text[*s..].len() >= m.end() - s && v["id"].as_str().is_some_and(|d| d.len() >= m.end() - s)) {                continue;            }            let a = arxiv_parse(m.as_str());            if a["valid"] == json!(true) {                found.push((m.start(), json!({ "type": "arxiv", "id": a["id"], "url": a["url"] })));            }        }    }    found.sort_by_key(|(s, _)| *s);    let mut out: Vec<Value> = vec![];    for (_, v) in found {        if !out.iter().any(|o| o["id"] == v["id"]) {            out.push(v);        }    }    out}
Check digits check_digits.qwer · 1.0.0Check digits made and checked: ISBN-10 and ISBN-13 with conversion, EAN-13, EAN-8 and UPC-A barcodes, IMEI, Luhn, ISO 7064 mod 97-10 references and Verhoeff.validationbarcodesbooksimport6 views · 7 downloadsISBN isbn.qwer · 1.0.0ISBNs checked, converted between 10 and 13 digits, hyphenated by the ISBN Agency's ranges into group, publisher and title parts with the group's language or country named, and every ISBN in a text found.booksvalidationcodesebooks6 views · 6 downloadsCard numbers cards.qwer · 1.0.0Payment card numbers checked before they reach a payment provider: Luhn check digit, brand and length, expiry, and the number formatted or masked for receipts.paymentsformscheckoutvalidation7 views · 7 downloadsNational ID numbers national_ids.qwer · 1.0.0National identity numbers of 29 countries checked by their own rules (US SSN, UK NINO, CPF, Aadhaar, PH TIN, DNI and NIE, codice fiscale, PESEL...), with the birth date and sex read out where the number holds them.validationidentitycomplianceforms7 views · 7 downloadsPassport MRZ passport_mrz.qwer · 1.0.0Machine-readable zones of passports, ID cards and visas (ICAO 9303 TD1, TD2, TD3, MRV) read into names, numbers and dates with every check digit verified, MRZ lines found in OCR text, and MRZs made for test documents.identitytravelocrvalidation6 views · 9 downloadsPhone and SIM numbers imei.qwer · 1.0.0The numbers that identify phones and SIM cards: IMEI and IMEISV read and made, CDMA MEIDs converted to decimal with their pseudo-ESN, SIM ICCIDs checked with their country, and IMSIs split into country and operator codes.phonedevicesmobilevalidation7 views · 11 downloads
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On Research identifiers: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.