📄 src/orcid/index.ts
D-OPEN SOVEREIGN
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library — The World Library
4 * 
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 * 
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15import type { StandardAdapter, StandardApiAdapter, ValidationResult }
16  from '@the_library/canonical-schemas/types/StandardAdapter';
17import type { OrcidPublicRecordResult, OrcidPublicSearchResult } from './types';
18
19interface ORCIDProps extends Record<string, string | null> { orcidId: string | null; }
20
21type ORCIDApis = {
22  'orcid-lookup':     StandardApiAdapter<ORCIDProps, OrcidPublicRecordResult>;
23  'orcid-public-api': StandardApiAdapter<ORCIDProps, OrcidPublicSearchResult[]>;
24};
25
26function validateORCIDMod11_2(compact: string): ValidationResult {
27  if (compact.length !== 16) return { valid: false, error: `Expected 16 chars, got ${compact.length}` };
28  let p = 0;
29  for (let i = 0; i < 15; i++) {
30    const d = parseInt(compact[i], 10);
31    if (isNaN(d)) return { valid: false, error: `Non-digit at position ${i}` };
32    p = ((p + d) * 2) % 11;
33  }
34  const checkVal  = (12 - p) % 11;
35  const checkChar = checkVal === 10 ? 'X' : String(checkVal);
36  if (compact[15].toUpperCase() !== checkChar)
37    return { valid: false, error: `Check digit mismatch: expected ${checkChar}, got ${compact[15]}` };
38  return { valid: true };
39}
40
41const ORCID_API_BASE = 'https://pub.orcid.org/v3.0';
42const ORCID_HEADERS  = { Accept: 'application/vnd.orcid+json' };
43
44export const ORCIDAdapter: StandardAdapter<ORCIDProps, ORCIDApis> = {
45  standardId: 'orcid',
46  uriScheme:  'ORCID',
47
48  normalize(value: string): string {
49    const clean = value.replace(/\s/g, '');
50    if (clean.length === 16 && !clean.includes('-'))
51      return `${clean.slice(0, 4)}-${clean.slice(4, 8)}-${clean.slice(8, 12)}-${clean.slice(12)}`;
52    return clean;
53  },
54
55  display(value: string): string {
56    return ORCIDAdapter.normalize(value);
57  },
58
59  validate(value: string | null | undefined): ValidationResult {
60    if (!value) return { valid: false, error: 'ORCID iD is absent' };
61    const normalized = ORCIDAdapter.normalize(value);
62    if (!/^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(normalized))
63      return { valid: false, error: `ORCID iD does not match pattern: ${normalized}` };
64    return validateORCIDMod11_2(normalized.replace(/-/g, ''));
65  },
66
67  toLinkedDataUri(value: string): string {
68    return `https://orcid.org/${ORCIDAdapter.normalize(value)}`;
69  },
70
71  apis: {
72    'orcid-lookup': {
73      apiId: 'orcid-lookup',
74      name:  'ORCID Public Record API',
75      async fetch({ orcidId }): Promise<OrcidPublicRecordResult> {
76        if (!orcidId) return {};
77        const res  = await fetch(`${ORCID_API_BASE}/${orcidId}/record`, { headers: ORCID_HEADERS });
78        if (!res.ok) return {};
79        const data   = await res.json() as Record<string, unknown>;
80        const person = (data.person ?? {}) as Record<string, unknown>;
81        const name   = (person.name  ?? {}) as Record<string, unknown>;
82        return {
83          orcidId,
84          givenNames: (name['given-names']  as { value?: string } | undefined)?.value,
85          familyName: (name['family-name']  as { value?: string } | undefined)?.value,
86          biography:  (person.biography     as { content?: string } | undefined)?.content ?? undefined,
87        };
88      },
89    },
90    'orcid-public-api': {
91      apiId: 'orcid-public-api',
92      name:  'ORCID Public Search API',
93      async fetch({ orcidId }): Promise<OrcidPublicSearchResult[]> {
94        if (!orcidId) return [];
95        const res = await fetch(
96          `${ORCID_API_BASE}/search?q=orcid:${orcidId}&rows=10`,
97          { headers: ORCID_HEADERS }
98        );
99        if (!res.ok) return [];
100        const data = await res.json() as { result?: unknown[] };
101        return (data.result ?? [])
102          .map((r: unknown) => {
103            const item = r as Record<string, unknown>;
104            const oid  = (item['orcid-identifier'] as Record<string, unknown> | undefined)?.path as string;
105            return { orcidId: oid ?? '' };
106          })
107          .filter(r => r.orcidId);
108      },
109    },
110  },
111};
112