Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
package eprel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://eprel.ec.europa.eu/api"
|
||||
defaultTimeout = 10 * time.Second
|
||||
defaultFicheLanguage = "EN"
|
||||
maxBodyBytes = 2 << 20
|
||||
)
|
||||
|
||||
var allowedFicheLanguages = map[string]struct{}{
|
||||
"EN": {}, "DE": {}, "FR": {}, "NL": {}, "ES": {}, "IT": {},
|
||||
}
|
||||
|
||||
func isAllowedFicheLanguage(code string) bool {
|
||||
_, ok := allowedFicheLanguages[code]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Data is the public energy-label payload attached to processed products.
|
||||
type Data struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
PDF string `json:"pdf,omitempty"`
|
||||
EnergyClass string `json:"energy_class,omitempty"`
|
||||
EnergyScale string `json:"energy_scale,omitempty"`
|
||||
}
|
||||
|
||||
// Fetcher is the test seam for EPREL HTTP calls.
|
||||
type Fetcher interface {
|
||||
Enabled() bool
|
||||
Fetch(ctx context.Context, eprelID string) (*Data, error)
|
||||
}
|
||||
|
||||
// Client calls the public EPREL product API with timeouts and bounded bodies.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
FicheLanguage string
|
||||
APIKey string // optional; never logged
|
||||
HTTP *http.Client
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// Options configures a Client.
|
||||
type Options struct {
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
Timeout time.Duration
|
||||
FicheLanguage string
|
||||
APIKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds an HTTP Fetcher. When Enabled is false, Fetch is a no-op.
|
||||
func NewClient(opts Options) *Client {
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultBaseURL
|
||||
}
|
||||
lang := strings.TrimSpace(opts.FicheLanguage)
|
||||
if lang == "" {
|
||||
lang = defaultFicheLanguage
|
||||
} else {
|
||||
lang = strings.ToUpper(lang)
|
||||
if !isAllowedFicheLanguage(lang) {
|
||||
lang = defaultFicheLanguage
|
||||
}
|
||||
}
|
||||
httpClient := opts.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = security.SafeHTTPClient(timeout, false)
|
||||
} else if httpClient.Timeout == 0 {
|
||||
cloned := *httpClient
|
||||
cloned.Timeout = timeout
|
||||
httpClient = &cloned
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: base,
|
||||
FicheLanguage: lang,
|
||||
APIKey: strings.TrimSpace(opts.APIKey),
|
||||
HTTP: httpClient,
|
||||
enabled: opts.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether EPREL enrichment is active.
|
||||
func (c *Client) Enabled() bool {
|
||||
return c != nil && c.enabled
|
||||
}
|
||||
|
||||
// Fetch loads label URL, product fiche PDF, and energy class for a registration id.
|
||||
// Partial success is returned when some sub-calls fail (label URL is always set for a valid id).
|
||||
func (c *Client) Fetch(ctx context.Context, eprelID string) (*Data, error) {
|
||||
if !c.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
id := NormalizeID(eprelID)
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &Data{
|
||||
ID: id,
|
||||
Label: fmt.Sprintf("%s/product/%s/labels?format=png", c.BaseURL, url.PathEscape(id)),
|
||||
}
|
||||
|
||||
if pdf, err := c.fetchFichePDF(ctx, id); err == nil && pdf != "" {
|
||||
out.PDF = pdf
|
||||
}
|
||||
if class, scale, err := c.fetchProductInfo(ctx, id); err == nil {
|
||||
out.EnergyClass = class
|
||||
out.EnergyScale = scale
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateID(id string) error {
|
||||
if len(id) > 64 {
|
||||
return fmt.Errorf("eprel id too long")
|
||||
}
|
||||
for _, r := range id {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' || r == '_' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("eprel id has invalid characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchFichePDF(ctx context.Context, id string) (string, error) {
|
||||
path := fmt.Sprintf("/product/%s/fiches", url.PathEscape(id))
|
||||
q := url.Values{}
|
||||
q.Set("noRedirect", "true")
|
||||
q.Set("language", c.FicheLanguage)
|
||||
raw, err := c.get(ctx, path, q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
addr := strings.TrimSpace(payload.Address)
|
||||
if addr == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
||||
return addr, nil
|
||||
}
|
||||
if !strings.HasPrefix(addr, "/") {
|
||||
addr = "/" + addr
|
||||
}
|
||||
origin := originFromBase(c.BaseURL)
|
||||
return origin + addr, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchProductInfo(ctx context.Context, id string) (class, scale string, err error) {
|
||||
raw, err := c.get(ctx, fmt.Sprintf("/product/%s", url.PathEscape(id)), nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var payload struct {
|
||||
EnergyClass string `json:"energyClass"`
|
||||
EnergyClassRange string `json:"energyClassRange"`
|
||||
EnergyScale string `json:"energyScale"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
class = strings.ReplaceAll(strings.TrimSpace(payload.EnergyClass), "_", "-")
|
||||
scale = strings.TrimSpace(payload.EnergyClassRange)
|
||||
if scale == "" {
|
||||
scale = strings.TrimSpace(payload.EnergyScale)
|
||||
}
|
||||
return class, scale, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||
u, err := url.Parse(c.BaseURL + path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query != nil {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Descrybe-EPREL/2.0")
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("X-API-KEY", c.APIKey)
|
||||
}
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
limited := io.LimitReader(res.Body, maxBodyBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("eprel response too large")
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("eprel api %s", strconv.Itoa(res.StatusCode))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func originFromBase(base string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "https://eprel.ec.europa.eu"
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// AttributeKeys are the processed_attributes keys written for exports/mapping.
|
||||
const (
|
||||
AttrID = "eprel_id"
|
||||
AttrLabel = "eprel_label"
|
||||
AttrLabelURL = "eprel_label_url"
|
||||
AttrPDF = "eprel_pdf"
|
||||
AttrPDFURL = "eprel_pdf_url"
|
||||
AttrEnergyClass = "eprel_energy_class"
|
||||
AttrEnergyScale = "eprel_energy_scale"
|
||||
)
|
||||
|
||||
// MergeInto copies EPREL fields into dest (creates map if nil). Returns dest.
|
||||
// Writes both flat eprel_* keys (export mapping) and a nested "eprel" object (API shape).
|
||||
func MergeInto(dest map[string]any, data *Data) map[string]any {
|
||||
if data == nil || data.ID == "" {
|
||||
return dest
|
||||
}
|
||||
if dest == nil {
|
||||
dest = map[string]any{}
|
||||
}
|
||||
dest[AttrID] = data.ID
|
||||
if data.Label != "" {
|
||||
dest[AttrLabel] = data.Label
|
||||
dest[AttrLabelURL] = data.Label
|
||||
}
|
||||
if data.PDF != "" {
|
||||
dest[AttrPDF] = data.PDF
|
||||
dest[AttrPDFURL] = data.PDF
|
||||
}
|
||||
if data.EnergyClass != "" {
|
||||
dest[AttrEnergyClass] = data.EnergyClass
|
||||
}
|
||||
if data.EnergyScale != "" {
|
||||
dest[AttrEnergyScale] = data.EnergyScale
|
||||
}
|
||||
nested := map[string]any{
|
||||
"id": data.ID,
|
||||
"label": data.Label,
|
||||
}
|
||||
if data.PDF != "" {
|
||||
nested["pdf"] = data.PDF
|
||||
}
|
||||
if data.EnergyClass != "" {
|
||||
nested["energy_class"] = data.EnergyClass
|
||||
}
|
||||
if data.EnergyScale != "" {
|
||||
nested["energy_scale"] = data.EnergyScale
|
||||
}
|
||||
dest["eprel"] = nested
|
||||
return dest
|
||||
}
|
||||
|
||||
// FieldValue returns a single export field from Data (empty when missing).
|
||||
func FieldValue(data *Data, fieldName string) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fieldName)) {
|
||||
case AttrID, "eprelid":
|
||||
return data.ID
|
||||
case AttrLabel, AttrLabelURL:
|
||||
return data.Label
|
||||
case AttrPDF, AttrPDFURL:
|
||||
return data.PDF
|
||||
case AttrEnergyClass:
|
||||
return data.EnergyClass
|
||||
case AttrEnergyScale:
|
||||
return data.EnergyScale
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled is a no-op Fetcher used when EPREL_ENABLED is false.
|
||||
type Disabled struct{}
|
||||
|
||||
func (Disabled) Enabled() bool { return false }
|
||||
|
||||
func (Disabled) Fetch(context.Context, string) (*Data, error) { return nil, nil }
|
||||
@@ -0,0 +1,160 @@
|
||||
package eprel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeAndExtractID(t *testing.T) {
|
||||
if got := NormalizeID(" 246834 "); got != "246834" {
|
||||
t.Fatalf("string=%q", got)
|
||||
}
|
||||
if got := NormalizeID(float64(246834)); got != "246834" {
|
||||
t.Fatalf("float=%q", got)
|
||||
}
|
||||
if got := NormalizeID(map[string]any{"#text": "99"}); got != "99" {
|
||||
t.Fatalf("xml text=%q", got)
|
||||
}
|
||||
if IsValidID("") || IsValidID(nil) {
|
||||
t.Fatal("empty should be invalid")
|
||||
}
|
||||
mapped := map[string]any{"title": "Fridge"}
|
||||
raw := map[string]any{"EPRELID": "12345"}
|
||||
if got := ExtractID(mapped, raw); got != "12345" {
|
||||
t.Fatalf("extract=%q", got)
|
||||
}
|
||||
mapped2 := map[string]any{"eprel_id": "777"}
|
||||
if got := ExtractID(mapped2); got != "777" {
|
||||
t.Fatalf("mapped key=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFetch_httptest(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/product/246834/fiches", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("noRedirect") != "true" {
|
||||
t.Errorf("missing noRedirect")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"address": "/fiches/demo/Fiche_246834_EN.pdf",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/product/246834", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "fiches") || strings.Contains(r.URL.Path, "labels") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"energyClass": "A_plus",
|
||||
"energyClassRange": "A-G",
|
||||
})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(Options{
|
||||
Enabled: true,
|
||||
BaseURL: srv.URL + "/api",
|
||||
Timeout: 2 * time.Second,
|
||||
HTTPClient: srv.Client(),
|
||||
})
|
||||
data, err := client.Fetch(context.Background(), "246834")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data == nil {
|
||||
t.Fatal("expected data")
|
||||
}
|
||||
if !strings.Contains(data.Label, "/product/246834/labels") {
|
||||
t.Fatalf("label=%q", data.Label)
|
||||
}
|
||||
if !strings.HasSuffix(data.PDF, "/fiches/demo/Fiche_246834_EN.pdf") {
|
||||
t.Fatalf("pdf=%q", data.PDF)
|
||||
}
|
||||
if data.EnergyClass != "A-plus" {
|
||||
t.Fatalf("class=%q", data.EnergyClass)
|
||||
}
|
||||
if data.EnergyScale != "A-G" {
|
||||
t.Fatalf("scale=%q", data.EnergyScale)
|
||||
}
|
||||
|
||||
attrs := MergeInto(nil, data)
|
||||
if attrs[AttrID] != "246834" || attrs[AttrEnergyClass] != "A-plus" {
|
||||
t.Fatalf("attrs=%v", attrs)
|
||||
}
|
||||
if FieldValue(data, "eprel_pdf_url") == "" {
|
||||
t.Fatal("FieldValue pdf empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDisabledAndInvalid(t *testing.T) {
|
||||
c := NewClient(Options{Enabled: false})
|
||||
if c.Enabled() {
|
||||
t.Fatal("should be disabled")
|
||||
}
|
||||
data, err := c.Fetch(context.Background(), "1")
|
||||
if err != nil || data != nil {
|
||||
t.Fatalf("disabled fetch: %v %#v", err, data)
|
||||
}
|
||||
enabled := NewClient(Options{Enabled: true, BaseURL: "http://127.0.0.1:1", Timeout: time.Millisecond})
|
||||
if _, err := enabled.Fetch(context.Background(), "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected invalid id error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPartialFicheFailure(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/product/1/fiches", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "gone", http.StatusNotFound)
|
||||
})
|
||||
mux.HandleFunc("/api/product/1", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"energyClass": "B"})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(Options{Enabled: true, BaseURL: srv.URL + "/api", Timeout: time.Second, HTTPClient: srv.Client()})
|
||||
data, err := client.Fetch(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data.PDF != "" {
|
||||
t.Fatalf("expected empty pdf, got %q", data.PDF)
|
||||
}
|
||||
if data.EnergyClass != "B" {
|
||||
t.Fatalf("class=%q", data.EnergyClass)
|
||||
}
|
||||
if data.Label == "" {
|
||||
t.Fatal("label should still be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyNotInErrorBodies(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-API-KEY") != "super-secret-key" {
|
||||
t.Errorf("missing api key header")
|
||||
}
|
||||
http.Error(w, "unauthorized secret=super-secret-key", http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := NewClient(Options{
|
||||
Enabled: true,
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "super-secret-key",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
// Fiche failure is soft; product info soft-fails too — Fetch still returns label-only data.
|
||||
data, err := client.Fetch(context.Background(), "9")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data == nil || data.Label == "" {
|
||||
t.Fatal("expected label-only result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package eprel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubFetcher verifies the Fetcher interface is usable from processing tests.
|
||||
type stubFetcher struct {
|
||||
enabled bool
|
||||
data *Data
|
||||
err error
|
||||
calls int
|
||||
lastID string
|
||||
}
|
||||
|
||||
func (s *stubFetcher) Enabled() bool { return s.enabled }
|
||||
|
||||
func (s *stubFetcher) Fetch(_ context.Context, id string) (*Data, error) {
|
||||
s.calls++
|
||||
s.lastID = id
|
||||
return s.data, s.err
|
||||
}
|
||||
|
||||
func TestFetcherInterface(t *testing.T) {
|
||||
var _ Fetcher = (*Client)(nil)
|
||||
var _ Fetcher = Disabled{}
|
||||
var _ Fetcher = (*stubFetcher)(nil)
|
||||
|
||||
st := &stubFetcher{enabled: true, data: &Data{ID: "1", Label: "L"}}
|
||||
got, err := st.Fetch(context.Background(), "1")
|
||||
if err != nil || got.ID != "1" || st.calls != 1 {
|
||||
t.Fatalf("stub: %#v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package eprel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var eprelIDKeys = []string{
|
||||
"eprel_id",
|
||||
"EPRELID",
|
||||
"eprelId",
|
||||
"EprelId",
|
||||
"eprelID",
|
||||
}
|
||||
|
||||
// NormalizeID coerces XML/API values (string, number, {"#text": ...}) to a trimmed ID.
|
||||
func NormalizeID(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case json.Number:
|
||||
s := strings.TrimSpace(t.String())
|
||||
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
case float64:
|
||||
if t != t { // NaN
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case float32:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case json.RawMessage:
|
||||
var decoded any
|
||||
if err := json.Unmarshal(t, &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
return NormalizeID(decoded)
|
||||
case map[string]any:
|
||||
if text, ok := t["#text"]; ok {
|
||||
return NormalizeID(text)
|
||||
}
|
||||
if text, ok := t["text"]; ok {
|
||||
return NormalizeID(text)
|
||||
}
|
||||
default:
|
||||
s := strings.TrimSpace(fmt.Sprint(t))
|
||||
if s == "" || s == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsValidID reports whether v normalizes to a non-empty EPREL registration id.
|
||||
func IsValidID(v any) bool {
|
||||
return NormalizeID(v) != ""
|
||||
}
|
||||
|
||||
// ExtractID finds an EPREL ID in mapped and/or raw product field maps
|
||||
// (vendor feeds often use <EPRELID/> / eprel_id).
|
||||
func ExtractID(sources ...map[string]any) string {
|
||||
for _, src := range sources {
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range eprelIDKeys {
|
||||
if id := NormalizeID(src[key]); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
for key, value := range src {
|
||||
compact := strings.ToLower(strings.ReplaceAll(key, "_", ""))
|
||||
if compact == "eprelid" {
|
||||
if id := NormalizeID(value); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user