Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package identifiers
import "testing"
func TestNormalizeDOI(t *testing.T) {
cases := map[string]string{
"10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
" https://doi.org/10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
"DOI: 10.1234/abcd": "10.1234/abcd",
"http://dx.doi.org/10.1/x": "http://dx.doi.org/10.1/x", // unknown host stays as it is
"": "",
}
for in, want := range cases {
if got := NormalizeDOI(in); got != want {
t.Errorf("NormalizeDOI(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidDOI(t *testing.T) {
for _, ok := range []string{
"10.1234/x", "10.5281/zenodo.1234567", "https://doi.org/10.1000/18.2011.01",
"10.1109/5.77101",
} {
if !ValidDOI(ok) {
t.Errorf("ValidDOI(%q) = false, want true", ok)
}
}
for _, bad := range []string{
"", "10.123/x", // needle too short
"10./x", // empty needle
"20.1234/x", // not the 10. prefix
"10.1234", // no suffix
"10.1234/ spaced suffix", // whitespace in the suffix
"doi:10.1234/", // empty suffix after the slash
} {
if ValidDOI(bad) {
t.Errorf("ValidDOI(%q) = true, want false", bad)
}
}
}
func TestValidORCID(t *testing.T) {
// A real public iD, and its computed X-check sibling.
for _, ok := range []string{
"0000-0002-1825-0097",
"0000-0000-0000-001X",
"0000-0000-0000-001x", // lower-case x is upper-cased first
" 0000-0002-1825-0097 ",
} {
if !ValidORCID(ok) {
t.Errorf("ValidORCID(%q) = false, want true", ok)
}
}
for _, bad := range []string{
"",
"0000-0002-1825-0098", // wrong check digit
"0000-0002-1825-009x", // right length, wrong check digit
"0000-0002-1825-009", // too short
"0000000218250097", // dashes missing
} {
if ValidORCID(bad) {
t.Errorf("ValidORCID(%q) = true, want false", bad)
}
}
}
func TestURLs(t *testing.T) {
if got := DOIURL("https://doi.org/10.1234/x"); got != "https://doi.org/10.1234/x" {
t.Errorf("DOIURL = %q", got)
}
if got := DOIURL("nonsense"); got != "" {
t.Errorf("DOIURL(nonsense) = %q, want empty", got)
}
if got := ORCIDURL("0000-0002-1825-0097"); got != "https://orcid.org/0000-0002-1825-0097" {
t.Errorf("ORCIDURL = %q", got)
}
if got := ORCIDURL("0000-0002-1825-0098"); got != "" {
t.Errorf("ORCIDURL(bad checksum) = %q, want empty", got)
}
}