// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Package identifiers validates the scholarly identifiers a post or an // account can carry: a DOI names the work, an ORCID names an author. It // is a leaf so the configuration, the domain object, the payload // builders and the admin share one rule without importing one another. // // The rules are syntactic on purpose. A DOI resolves through doi.org and // an ORCID through orcid.org, and both publish registries; asking those // services here would tie a save to the network, leak who is writing to // a third party, and make the offline binary wait on someone else's // uptime. Syntax plus the ORCID check digit catches every realistic // typo; resolution stays the consumer's job. package identifiers import ( "regexp" "strings" ) var doiRe = regexp.MustCompile(`\A10\.[0-9]{4,9}/\S+\z`) // orcidRe is the display form: four groups of four digits, the last // character a digit or the uppercase X that stands for a checksum of 10. var orcidRe = regexp.MustCompile(`\A[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]\z`) // NormalizeDOI accepts the bare form, a doi: scheme and a doi.org URL, // and returns the bare identifier; an empty result means the input // carries no DOI at all. func NormalizeDOI(text string) string { text = strings.TrimSpace(text) for _, prefix := range []string{ "https://doi.org/", "http://doi.org/", "doi.org/", "doi:", } { if len(text) > len(prefix) && strings.EqualFold(text[:len(prefix)], prefix) { text = strings.TrimSpace(text[len(prefix):]) break } } return text } // ValidDOI reports whether text is a syntactically valid bare DOI: the // 10. prefix, a registrant needle of four to nine digits, a slash, and // a non-empty suffix without spaces. func ValidDOI(text string) bool { return doiRe.MatchString(NormalizeDOI(text)) } // NormalizeORCID trims and lower-cases to upper; it fixes no body. func NormalizeORCID(text string) string { return strings.ToUpper(strings.TrimSpace(text)) } // ValidORCID reports whether text is an ORCID iD in display form with a // correct ISO 7064 (MOD 11-2) check digit. func ValidORCID(text string) bool { id := NormalizeORCID(text) if !orcidRe.MatchString(id) { return false } digits := strings.ReplaceAll(id, "-", "") total := 0 for _, r := range digits[:15] { total = (total + int(r-'0')) * 2 } remainder := (12 - total%11) % 11 want := byte('0' + remainder) if remainder == 10 { want = 'X' } return digits[15] == want } // DOIURL names the resolver for a bare or already-prefixed DOI; an // invalid input yields "". func DOIURL(text string) string { doi := NormalizeDOI(text) if !doiRe.MatchString(doi) { return "" } return "https://doi.org/" + doi } // ORCIDURL names the public record for a valid iD; an invalid input // yields "". func ORCIDURL(text string) string { id := NormalizeORCID(text) if !ValidORCID(id) { return "" } return "https://orcid.org/" + id }