dragoman 0.4.75

Server for scholarly metadata in commonmeta format stored in a SQLite database.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
import katex from 'katex'

export const TYPE_LABELS = {
  'de-DE': {
    Article: 'Artikel', Audiovisual: 'Audiovisuell', BlogPost: 'Blogbeitrag',
    Book: 'Buch', BookChapter: 'Buchkapitel', BookSeries: 'Buchreihe',
    Collection: 'Sammlung', Component: 'Komponente', ComputationalNotebook: 'Computational Notebook',
    DataPaper: 'Datenpapier', Dataset: 'Datensatz', Dissertation: 'Dissertation',
    Document: 'Dokument', Entry: 'Eintrag', Event: 'Veranstaltung',
    Figure: 'Abbildung', Grant: 'Förderantrag', Image: 'Bild',
    Instrument: 'Instrument', InteractiveResource: 'Interaktive Ressource',
    Journal: 'Zeitschrift', JournalArticle: 'Zeitschriftenartikel',
    LegalDocument: 'Rechtsdokument', Manuscript: 'Manuskript', Map: 'Karte',
    Model: 'Modell', Other: 'Sonstiges', OutputManagementPlan: 'Datenmanagementplan',
    Patent: 'Patent', PeerReview: 'Fachgutachten', Performance: 'Aufführung',
    PersonalCommunication: 'Persönliche Mitteilung', PhysicalObject: 'Physisches Objekt',
    Poster: 'Poster', Presentation: 'Präsentation', Preprint: 'Preprint',
    ProceedingsArticle: 'Konferenzbeitrag', Report: 'Bericht', Service: 'Dienst',
    Software: 'Software', Sound: 'Ton', Standard: 'Norm',
    StudyRegistration: 'Studienregistrierung', WebPage: 'Webseite',
  },
  'fr-FR': {
    Article: 'Article', Audiovisual: 'Audiovisuel', BlogPost: 'Article de blog',
    Book: 'Livre', BookChapter: 'Chapitre de livre', BookSeries: 'Série de livres',
    Collection: 'Collection', Component: 'Composant', ComputationalNotebook: 'Carnet de calcul',
    DataPaper: 'Article de données', Dataset: 'Ensemble de données', Dissertation: 'Thèse',
    Document: 'Document', Entry: 'Entrée', Event: 'Événement',
    Figure: 'Figure', Grant: 'Subvention', Image: 'Image',
    Instrument: 'Instrument', InteractiveResource: 'Ressource interactive',
    Journal: 'Revue', JournalArticle: 'Article de revue',
    LegalDocument: 'Document juridique', Manuscript: 'Manuscrit', Map: 'Carte',
    Model: 'Modèle', Other: 'Autre', OutputManagementPlan: 'Plan de gestion des données',
    Patent: 'Brevet', PeerReview: 'Évaluation par les pairs', Performance: 'Représentation',
    PersonalCommunication: 'Communication personnelle', PhysicalObject: 'Objet physique',
    Poster: 'Affiche', Presentation: 'Présentation', Preprint: 'Préimpression',
    ProceedingsArticle: 'Article de conférence', Report: 'Rapport', Service: 'Service',
    Software: 'Logiciel', Sound: 'Son', Standard: 'Norme',
    StudyRegistration: "Enregistrement d'étude", WebPage: 'Page web',
  },
  'es-ES': {
    Article: 'Artículo', Audiovisual: 'Audiovisual', BlogPost: 'Entrada de blog',
    Book: 'Libro', BookChapter: 'Capítulo de libro', BookSeries: 'Serie de libros',
    Collection: 'Colección', Component: 'Componente', ComputationalNotebook: 'Cuaderno computacional',
    DataPaper: 'Artículo de datos', Dataset: 'Conjunto de datos', Dissertation: 'Tesis',
    Document: 'Documento', Entry: 'Entrada', Event: 'Evento',
    Figure: 'Figura', Grant: 'Subvención', Image: 'Imagen',
    Instrument: 'Instrumento', InteractiveResource: 'Recurso interactivo',
    Journal: 'Revista', JournalArticle: 'Artículo de revista',
    LegalDocument: 'Documento legal', Manuscript: 'Manuscrito', Map: 'Mapa',
    Model: 'Modelo', Other: 'Otro', OutputManagementPlan: 'Plan de gestión de datos',
    Patent: 'Patente', PeerReview: 'Revisión por pares', Performance: 'Actuación',
    PersonalCommunication: 'Comunicación personal', PhysicalObject: 'Objeto físico',
    Poster: 'Póster', Presentation: 'Presentación', Preprint: 'Preimpresión',
    ProceedingsArticle: 'Artículo de conferencia', Report: 'Informe', Service: 'Servicio',
    Software: 'Software', Sound: 'Sonido', Standard: 'Norma',
    StudyRegistration: 'Registro de estudio', WebPage: 'Página web',
  },
  'it-IT': {
    Article: 'Articolo', Audiovisual: 'Audiovisivo', BlogPost: 'Articolo di blog',
    Book: 'Libro', BookChapter: 'Capitolo di libro', BookSeries: 'Serie di libri',
    Collection: 'Raccolta', Component: 'Componente', ComputationalNotebook: 'Quaderno computazionale',
    DataPaper: 'Articolo di dati', Dataset: 'Set di dati', Dissertation: 'Tesi',
    Document: 'Documento', Entry: 'Voce', Event: 'Evento',
    Figure: 'Figura', Grant: 'Finanziamento', Image: 'Immagine',
    Instrument: 'Strumento', InteractiveResource: 'Risorsa interattiva',
    Journal: 'Rivista', JournalArticle: 'Articolo di rivista',
    LegalDocument: 'Documento legale', Manuscript: 'Manoscritto', Map: 'Mappa',
    Model: 'Modello', Other: 'Altro', OutputManagementPlan: 'Piano di gestione dei dati',
    Patent: 'Brevetto', PeerReview: 'Revisione paritaria', Performance: 'Rappresentazione',
    PersonalCommunication: 'Comunicazione personale', PhysicalObject: 'Oggetto fisico',
    Poster: 'Poster', Presentation: 'Presentazione', Preprint: 'Preprint',
    ProceedingsArticle: 'Atti di conferenza', Report: 'Rapporto', Service: 'Servizio',
    Software: 'Software', Sound: 'Suono', Standard: 'Norma',
    StudyRegistration: 'Registrazione dello studio', WebPage: 'Pagina web',
  },
  'ja-JP': {
    Article: '論文', Audiovisual: '映像・音声', BlogPost: 'ブログ記事',
    Book: '図書', BookChapter: '書籍の章', BookSeries: '叢書',
    Collection: 'コレクション', Component: 'コンポーネント', ComputationalNotebook: '計算ノートブック',
    DataPaper: 'データ論文', Dataset: 'データセット', Dissertation: '学位論文',
    Document: '文書', Entry: '項目', Event: 'イベント',
    Figure: '図', Grant: '研究助成', Image: '画像',
    Instrument: '機器', InteractiveResource: 'インタラクティブリソース',
    Journal: '学術誌', JournalArticle: '学術論文',
    LegalDocument: '法的文書', Manuscript: '原稿', Map: '地図',
    Model: 'モデル', Other: 'その他', OutputManagementPlan: 'データ管理計画',
    Patent: '特許', PeerReview: '査読', Performance: 'パフォーマンス',
    PersonalCommunication: '個人通信', PhysicalObject: '物理的対象',
    Poster: 'ポスター', Presentation: 'プレゼンテーション', Preprint: 'プレプリント',
    ProceedingsArticle: '会議論文', Report: '報告書', Service: 'サービス',
    Software: 'ソフトウェア', Sound: '音声', Standard: '規格',
    StudyRegistration: '研究登録', WebPage: 'ウェブページ',
  },
  'ko-KR': {
    Article: '논문', Audiovisual: '시청각 자료', BlogPost: '블로그 게시물',
    Book: '도서', BookChapter: '책 챕터', BookSeries: '도서 시리즈',
    Collection: '컬렉션', Component: '컴포넌트', ComputationalNotebook: '계산 노트북',
    DataPaper: '데이터 논문', Dataset: '데이터셋', Dissertation: '학위논문',
    Document: '문서', Entry: '항목', Event: '이벤트',
    Figure: '그림', Grant: '연구 지원금', Image: '이미지',
    Instrument: '기기', InteractiveResource: '대화형 자료',
    Journal: '학술지', JournalArticle: '학술 논문',
    LegalDocument: '법률 문서', Manuscript: '원고', Map: '지도',
    Model: '모델', Other: '기타', OutputManagementPlan: '데이터 관리 계획',
    Patent: '특허', PeerReview: '동료 심사', Performance: '공연',
    PersonalCommunication: '개인 통신', PhysicalObject: '물리적 객체',
    Poster: '포스터', Presentation: '발표', Preprint: '프리프린트',
    ProceedingsArticle: '학술대회 논문', Report: '보고서', Service: '서비스',
    Software: '소프트웨어', Sound: '음성', Standard: '표준',
    StudyRegistration: '연구 등록', WebPage: '웹 페이지',
  },
  'nl-NL': {
    Article: 'Artikel', Audiovisual: 'Audiovisueel', BlogPost: 'Blogbericht',
    Book: 'Boek', BookChapter: 'Boekhoofdstuk', BookSeries: 'Boekreeks',
    Collection: 'Collectie', Component: 'Component', ComputationalNotebook: 'Berekeningsnotebook',
    DataPaper: 'Datapaper', Dataset: 'Dataset', Dissertation: 'Proefschrift',
    Document: 'Document', Entry: 'Invoer', Event: 'Evenement',
    Figure: 'Figuur', Grant: 'Subsidie', Image: 'Afbeelding',
    Instrument: 'Instrument', InteractiveResource: 'Interactieve bron',
    Journal: 'Tijdschrift', JournalArticle: 'Tijdschriftartikel',
    LegalDocument: 'Juridisch document', Manuscript: 'Manuscript', Map: 'Kaart',
    Model: 'Model', Other: 'Overig', OutputManagementPlan: 'Data-managementplan',
    Patent: 'Octrooi', PeerReview: 'Collegiale toetsing', Performance: 'Uitvoering',
    PersonalCommunication: 'Persoonlijke communicatie', PhysicalObject: 'Fysiek object',
    Poster: 'Poster', Presentation: 'Presentatie', Preprint: 'Preprint',
    ProceedingsArticle: 'Congresartikel', Report: 'Rapport', Service: 'Dienst',
    Software: 'Software', Sound: 'Geluid', Standard: 'Norm',
    StudyRegistration: 'Studieregistratie', WebPage: 'Webpagina',
  },
  'pt-BR': {
    Article: 'Artigo', Audiovisual: 'Audiovisual', BlogPost: 'Postagem de blog',
    Book: 'Livro', BookChapter: 'Capítulo de livro', BookSeries: 'Série de livros',
    Collection: 'Coleção', Component: 'Componente', ComputationalNotebook: 'Caderno computacional',
    DataPaper: 'Artigo de dados', Dataset: 'Conjunto de dados', Dissertation: 'Dissertação',
    Document: 'Documento', Entry: 'Entrada', Event: 'Evento',
    Figure: 'Figura', Grant: 'Financiamento de pesquisa', Image: 'Imagem',
    Instrument: 'Instrumento', InteractiveResource: 'Recurso interativo',
    Journal: 'Periódico', JournalArticle: 'Artigo de periódico',
    LegalDocument: 'Documento legal', Manuscript: 'Manuscrito', Map: 'Mapa',
    Model: 'Modelo', Other: 'Outro', OutputManagementPlan: 'Plano de gestão de dados',
    Patent: 'Patente', PeerReview: 'Revisão por pares', Performance: 'Apresentação artística',
    PersonalCommunication: 'Comunicação pessoal', PhysicalObject: 'Objeto físico',
    Poster: 'Pôster', Presentation: 'Apresentação', Preprint: 'Preprint',
    ProceedingsArticle: 'Artigo de conferência', Report: 'Relatório', Service: 'Serviço',
    Software: 'Software', Sound: 'Som', Standard: 'Norma',
    StudyRegistration: 'Registro de estudo', WebPage: 'Página web',
  },
  'sv-SE': {
    Article: 'Artikel', Audiovisual: 'Audiovisuellt material', BlogPost: 'Blogginlägg',
    Book: 'Bok', BookChapter: 'Bokkapitel', BookSeries: 'Bokserie',
    Collection: 'Samling', Component: 'Komponent', ComputationalNotebook: 'Beräkningsnotatbok',
    DataPaper: 'Datapaper', Dataset: 'Dataset', Dissertation: 'Avhandling',
    Document: 'Dokument', Entry: 'Post', Event: 'Evenemang',
    Figure: 'Figur', Grant: 'Anslag', Image: 'Bild',
    Instrument: 'Instrument', InteractiveResource: 'Interaktiv resurs',
    Journal: 'Tidskrift', JournalArticle: 'Tidskriftsartikel',
    LegalDocument: 'Juridiskt dokument', Manuscript: 'Manuskript', Map: 'Karta',
    Model: 'Modell', Other: 'Övrigt', OutputManagementPlan: 'Datahanteringsplan',
    Patent: 'Patent', PeerReview: 'Kollegial granskning', Performance: 'Föreställning',
    PersonalCommunication: 'Personlig kommunikation', PhysicalObject: 'Fysiskt objekt',
    Poster: 'Poster', Presentation: 'Presentation', Preprint: 'Preprint',
    ProceedingsArticle: 'Konferensartikel', Report: 'Rapport', Service: 'Tjänst',
    Software: 'Programvara', Sound: 'Ljud', Standard: 'Standard',
    StudyRegistration: 'Studieregistrering', WebPage: 'Webbsida',
  },
  'zh-CN': {
    Article: '文章', Audiovisual: '视听资料', BlogPost: '博客文章',
    Book: '图书', BookChapter: '书目章节', BookSeries: '丛书',
    Collection: '集合', Component: '组件', ComputationalNotebook: '计算笔记本',
    DataPaper: '数据论文', Dataset: '数据集', Dissertation: '学位论文',
    Document: '文档', Entry: '条目', Event: '活动',
    Figure: '图表', Grant: '科研经费', Image: '图像',
    Instrument: '仪器', InteractiveResource: '交互式资源',
    Journal: '期刊', JournalArticle: '期刊文章',
    LegalDocument: '法律文件', Manuscript: '手稿', Map: '地图',
    Model: '模型', Other: '其他', OutputManagementPlan: '数据管理计划',
    Patent: '专利', PeerReview: '同行评审', Performance: '表演',
    PersonalCommunication: '个人通信', PhysicalObject: '实体对象',
    Poster: '海报', Presentation: '演示文稿', Preprint: '预印本',
    ProceedingsArticle: '会议论文', Report: '报告', Service: '服务',
    Software: '软件', Sound: '音频', Standard: '标准',
    StudyRegistration: '研究注册', WebPage: '网页',
  },
}

export function formatCount(n, locale) {
  return new Intl.NumberFormat(locale).format(n)
}

export function typeLabel(type, locale) {
  if (!type) return ''
  return TYPE_LABELS[locale]?.[type] ?? type.replace(/([A-Z])/g, ' $1').trim()
}

// Mirrors $defs/type enum in commonmeta_v1.0rc1.json
export const WORK_TYPES = [
  'Audiovisual', 'Blog', 'BlogPost', 'BlogVolume', 'Book', 'BookChapter',
  'BookPart', 'BookSection', 'BookSeries', 'BookSet', 'Collection', 'Component',
  'ComputationalNotebook', 'Database', 'Dataset', 'Dissertation', 'Document', 'Entry',
  'Event', 'Figure', 'Grant', 'Image', 'Instrument', 'InteractiveResource', 'Journal',
  'JournalArticle', 'JournalIssue', 'JournalVolume', 'LegalDocument', 'Manuscript',
  'Map', 'Model', 'OutputManagementPlan', 'Patent', 'PeerReview', 'Performance',
  'PersonalCommunication', 'PhysicalObject', 'Post', 'Poster', 'Presentation', 'Preprint',
  'Proceedings', 'ProceedingsArticle', 'ProceedingsSeries', 'Report', 'ReportComponent',
  'ReportSeries', 'Review', 'Service', 'Software', 'Sound', 'Standard',
  'StudyRegistration', 'WebPage', 'Workflow', 'Other',
]

export function formatDate(dateStr, locale) {
  if (!dateStr) return ''
  const parts = dateStr.split('-').map(Number)
  if (parts.length >= 3) {
    return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric' })
      .format(new Date(parts[0], parts[1] - 1, parts[2]))
  } else if (parts.length === 2) {
    return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long' })
      .format(new Date(parts[0], parts[1] - 1, 1))
  }
  return String(parts[0])
}

export function languageLabel(lang, locale) {
  if (!lang) return ''
  try {
    return new Intl.DisplayNames([locale], { type: 'language' }).of(lang) ?? lang
  } catch {
    return lang
  }
}

export function getCCIcons(id) {
  if (!id) return []
  const u = id.toUpperCase()
  if (!u.startsWith('CC')) return []
  const icons = ['fa-brands:creative-commons']
  if (u.startsWith('CC0') || u.includes('ZERO')) {
    icons.push('fa-brands:creative-commons-zero')
  } else {
    if (u.includes('-BY'))  icons.push('fa-brands:creative-commons-by')
    if (u.includes('-SA'))  icons.push('fa-brands:creative-commons-sa')
    if (u.includes('-NC'))  icons.push('fa-brands:creative-commons-nc')
    if (u.includes('-ND'))  icons.push('fa-brands:creative-commons-nd')
  }
  return icons
}

const KATEX_MACROS = {
  '\\upalpha': '\\alpha', '\\upbeta': '\\beta', '\\upgamma': '\\gamma',
  '\\updelta': '\\delta', '\\upepsilon': '\\epsilon', '\\upzeta': '\\zeta',
  '\\upeta': '\\eta', '\\uptheta': '\\theta', '\\upiota': '\\iota',
  '\\upkappa': '\\kappa', '\\uplambda': '\\lambda', '\\upmu': '\\mu',
  '\\upnu': '\\nu', '\\upxi': '\\xi', '\\uppi': '\\pi',
  '\\uprho': '\\rho', '\\upsigma': '\\sigma', '\\uptau': '\\tau',
  '\\upupsilon': '\\upsilon', '\\upphi': '\\phi', '\\upchi': '\\chi',
  '\\uppsi': '\\psi', '\\upomega': '\\omega',
}

export function decodeEntities(str) {
  if (!str) return str
  const doc = new DOMParser().parseFromString(str, 'text/html')
  return doc.body.textContent
}

const INLINE_TAGS = new Set(['b', 'i', 'strong', 'em', 'sup', 'sub'])

const ENCODED_TAG_RE = /<(\/?(?:b|i|em|strong|sup|sub))>/gi

function sanitizePlain(text) {
  if (!text) return ''
  // Decode HTML-entity-encoded inline tags (e.g. &lt;sup&gt; → <sup>)
  const html = text.replace(ENCODED_TAG_RE, '<$1>')
  const doc = new DOMParser().parseFromString(html, 'text/html')
  function walk(node) {
    if (node.nodeType === 3) {
      return node.textContent
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    }
    if (node.nodeType !== 1) return ''
    const tag = node.tagName.toLowerCase()
    const children = Array.from(node.childNodes).map(walk).join('')
    return INLINE_TAGS.has(tag) ? `<${tag}>${children}</${tag}>` : children
  }
  return Array.from(doc.body.childNodes).map(walk).join('')
}

export function renderContent(text) {
  if (!text) return ''
  // Collapse JATS XML whitespace and strip MathML fallback single-char lines
  const cleaned = text
    .split('\n')
    .filter(line => { const t = line.trim(); return t.length === 0 || t.length > 2 })
    .join(' ')
    .replace(/\s{2,}/g, ' ')
    .trim()
    .replace(/^(?:A\s+)?bstract\s+/i, '')
  const s = cleaned.replace(/&(\d+);/g, '&#$1;')
  const parts = []
  let i = 0
  while (i < s.length) {
    if (s[i] === '$' && s[i + 1] === '$') {
      const end = s.indexOf('$$', i + 2)
      if (end !== -1) {
        parts.push(katex.renderToString(s.slice(i + 2, end), { displayMode: false, throwOnError: false, macros: KATEX_MACROS }))
        i = end + 2
        continue
      }
    }
    if (s[i] === '$') {
      const end = s.indexOf('$', i + 1)
      if (end !== -1) {
        parts.push(katex.renderToString(s.slice(i + 1, end), { displayMode: false, throwOnError: false, macros: KATEX_MACROS }))
        i = end + 1
        continue
      }
    }
    // Search from i+1: when s[i] is an unmatched '$' (e.g. a lone currency sign),
    // indexOf('$', i) returns i, so i never advances and parts grows until it exceeds
    // the max array length (RangeError). Starting at i+1 emits the '$' as plain text.
    const next = s.indexOf('$', i + 1)
    parts.push(sanitizePlain(next === -1 ? s.slice(i) : s.slice(i, next)))
    i = next === -1 ? s.length : next
  }
  return parts.join('')
}

// Maps "Has*" relation types to their plural noun label; all others are
// split at CamelCase boundaries and lowercased after the first character.
const HAS_PLURAL = {
  HasMetadata: 'Metadata', HasPart: 'Parts', HasPreprint: 'Preprints',
  HasReview: 'Reviews', HasTranslation: 'Translations', HasVersion: 'Versions',
  IsReferencedBy: 'Citations',
}

export function humanizeRelationType(type) {
  if (HAS_PLURAL[type]) return HAS_PLURAL[type]
  const words = type.replace(/([A-Z])/g, ' $1').trim()
  return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase()
}

// Singular and plural noun labels used in the count summary row on index cards.
const COUNT_LABELS = {
  HasMetadata:         ['metadata',          'metadata'],
  HasPart:             ['part',              'parts'],
  HasPreprint:         ['preprint',          'preprints'],
  HasReview:           ['review',            'reviews'],
  HasTranslation:      ['translation',       'translations'],
  HasVersion:          ['version',           'versions'],
  IsDerivedFrom:       ['derived from',      'derived from'],
  IsIdenticalTo:       ['identical to',      'identical to'],
  IsMetadataFor:       ['metadata for',      'metadata for'],
  IsNewVersionOf:      ['new version of',    'new versions of'],
  IsOriginalFormOf:    ['original form',     'original forms'],
  IsPartOf:            ['part of',           'parts of'],
  IsPreprintOf:        ['preprint',          'preprints'],
  IsPreviousVersionOf: ['previous version',  'previous versions'],
  IsReferencedBy:      ['citation',          'citations'],
  IsReviewOf:          ['review',            'reviews'],
  IsSourceOf:          ['source of',         'sources of'],
  IsSupplementTo:      ['supplement to',     'supplements to'],
  IsSupplementedBy:    ['supplement',        'supplements'],
  IsTranslationOf:     ['translation',       'translations'],
  IsVariantFormOf:     ['variant form',      'variant forms'],
  IsVersionOf:         ['version of',        'versions of'],
}

export function countLabel(type, count) {
  const pair = COUNT_LABELS[type]
  if (!pair) return count === 1 ? type : type + 's'
  return count === 1 ? pair[0] : pair[1]
}

// Preferred display order for relation-type cards on entity pages.
export const RELATION_TYPE_ORDER = [
  'IsReviewOf', 'HasVersion', 'IsVersionOf', 'IsNewVersionOf', 'IsPreviousVersionOf',
  'HasPart', 'IsPartOf', 'HasReview', 'IsPreprintOf', 'HasPreprint',
  'IsSupplementTo', 'IsSupplementedBy',
  'IsTranslationOf', 'HasTranslation', 'IsVariantFormOf', 'IsOriginalFormOf',
  'IsDerivedFrom', 'IsSourceOf', 'IsIdenticalTo', 'IsMetadataFor', 'HasMetadata',
  'IsReferencedBy',
]

/**
 * The local path for a pid, carrying `?noredirect`.
 *
 * A bare `/{pid}` resolves to the landing page of record, the way the handle
 * proxy at doi.org does, so every link meant to open this site's own entity
 * page has to ask for the page explicitly. Accepts a resolver URL
 * (`https://doi.org/10.x`, orcid.org, ror.org), an already-bare identifier, or
 * one with a leading slash, and leaves an existing query string alone.
 */
export function entityPath(id) {
  if (!id) return id
  const bare = String(id)
    .replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
    .replace(/^https?:\/\/orcid\.org\//, '')
    .replace(/^https?:\/\/ror\.org\//, '')
    .replace(/^\//, '')
  return bare.includes('?') ? `/${bare}` : `/${bare}?noredirect`
}

// A container record carries no `id`, only `identifiers`. It is bookmarked under
// the one its page path prefers — ISSN, then OpenAlex, DOI, DataCite — spelled
// as a URL like every other bookmark id, so a bookmark link, an export and an
// import can all treat bookmarks alike.
const CONTAINER_ID_URLS = [
  ['ISSN', 'https://portal.issn.org/resource/ISSN/'],
  ['OpenAlex', 'https://openalex.org/'],
  ['DOI', 'https://doi.org/'],
  ['DataCite', 'https://commons.datacite.org/repositories/'],
]

export function containerId(d) {
  for (const [type, base] of CONTAINER_ID_URLS) {
    const value = d?.identifiers?.find(i => i.identifier_type === type)?.identifier
    if (value) return /^https?:\/\//.test(value) ? value : base + value
  }
  return ''
}

/** The id a record is bookmarked under: its own, or a container's. */
export function recordId(d) {
  return d?.id ?? containerId(d)
}

/** A bookmark id as its page path spells it: the bare DOI, ORCID iD, ROR id, ISSN, … */
export function bareBookmarkId(id) {
  const s = String(id)
  for (const [, base] of CONTAINER_ID_URLS) {
    if (s.startsWith(base)) return s.slice(base.length)
  }
  return s
    .replace(/^https?:\/\/(?:dx\.)?doi\.org\//, '')
    .replace(/^https?:\/\/orcid\.org\//, '')
    .replace(/^https?:\/\/ror\.org\//, '')
}

export function bookmarkPath(id) {
  return id ? entityPath(bareBookmarkId(id)) : id
}

/**
 * The one record a bookmark holds. An entity page keeps its record as a
 * one-element array — the shape `?format=commonmeta` returns for a person,
 * organization or container — and bookmarks saved from its card stored that
 * array as is, so readers unwrap it.
 */
export function bookmarkRecord(data) {
  try {
    const d = typeof data === 'string' ? JSON.parse(data) : data
    return (Array.isArray(d) ? d[0] : d) ?? null
  } catch { return null }
}