@markuxt/markuxt 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/action.yml +24 -0
- package/package.json +7 -1
- package/src/actions/sync-publications/index.ts +488 -0
- package/src/components/MemberCard.vue +13 -0
- package/src/pages/members/[...slug].vue +150 -0
- package/src/public/_markuxt/actions/sync-publications/index.ts +488 -0
- package/src/public/_markuxt/components/MemberCard.vue +13 -0
- package/src/public/_markuxt/pages/members/[...slug].vue +150 -0
|
@@ -70,6 +70,34 @@
|
|
|
70
70
|
<ContentRenderer :value="member" />
|
|
71
71
|
</div>
|
|
72
72
|
</div>
|
|
73
|
+
|
|
74
|
+
<!-- Publications -->
|
|
75
|
+
<div v-if="memberPublications.length > 0" class="profile-section animate-fade-in-up delay-400">
|
|
76
|
+
<div class="profile-section__header">
|
|
77
|
+
<FileText class="icon-inline" theme="outline" :size="22" fill="white" :stroke-width="2.8" />
|
|
78
|
+
<h3>{{ t('members.publications') }}</h3>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="profile-section__body">
|
|
81
|
+
<div class="publications-grid">
|
|
82
|
+
<NuxtLink
|
|
83
|
+
v-for="pub in memberPublications"
|
|
84
|
+
:key="pub._id"
|
|
85
|
+
:to="pub._path"
|
|
86
|
+
class="publication-card"
|
|
87
|
+
>
|
|
88
|
+
<div class="publication-card__content">
|
|
89
|
+
<h4 class="publication-card__title">{{ pub.title }}</h4>
|
|
90
|
+
<p class="publication-card__authors">{{ formatAuthors(pub.authors) }}</p>
|
|
91
|
+
<div class="publication-card__meta">
|
|
92
|
+
<span class="publication-card__venue">{{ pub.venue }}</span>
|
|
93
|
+
<span class="publication-card__year">{{ pub.year }}</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<ArrowRight class="publication-card__arrow" theme="outline" :size="16" fill="currentColor" :stroke-width="2" />
|
|
97
|
+
</NuxtLink>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
73
101
|
</div>
|
|
74
102
|
</div>
|
|
75
103
|
</main>
|
|
@@ -93,6 +121,8 @@ import Mail from '@icon-park/vue-next/es/icons/Mail'
|
|
|
93
121
|
import Google from '@icon-park/vue-next/es/icons/Google'
|
|
94
122
|
import Search from '@icon-park/vue-next/es/icons/Search'
|
|
95
123
|
import FileStaff from '@icon-park/vue-next/es/icons/FileStaff'
|
|
124
|
+
import FileText from '@icon-park/vue-next/es/icons/FileText'
|
|
125
|
+
import ArrowRight from '@icon-park/vue-next/es/icons/ArrowRight'
|
|
96
126
|
import Help from '@icon-park/vue-next/es/icons/Help'
|
|
97
127
|
|
|
98
128
|
const { t } = useI18n()
|
|
@@ -120,6 +150,40 @@ const { data: memberData } = await useAsyncData(`member-${slug.value}`, async ()
|
|
|
120
150
|
|
|
121
151
|
const member = computed(() => memberData.value)
|
|
122
152
|
|
|
153
|
+
// Fetch all publications for filtering by member ORCID
|
|
154
|
+
const { data: allPublications } = await useAsyncData('publications', async () => {
|
|
155
|
+
try {
|
|
156
|
+
return await queryContent('/publications')
|
|
157
|
+
.where({ _hidden: { $ne: true } })
|
|
158
|
+
.find()
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.error('Error fetching publications:', e)
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Filter publications by member's ORCID
|
|
166
|
+
const memberPublications = computed(() => {
|
|
167
|
+
if (!member.value?.orcid || !allPublications.value) return []
|
|
168
|
+
|
|
169
|
+
const memberOrcid = member.value.orcid.trim()
|
|
170
|
+
|
|
171
|
+
return (allPublications.value || [])
|
|
172
|
+
.filter(pub => {
|
|
173
|
+
const authorsOrcid = pub.authors_orcid
|
|
174
|
+
if (!Array.isArray(authorsOrcid)) return false
|
|
175
|
+
return authorsOrcid.some(orcid => orcid === memberOrcid)
|
|
176
|
+
})
|
|
177
|
+
.sort((a, b) => (b.year || 0) - (a.year || 0)) // Sort by year descending
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Format authors list for display
|
|
181
|
+
const formatAuthors = (authors: string[] | undefined) => {
|
|
182
|
+
if (!Array.isArray(authors) || authors.length === 0) return ''
|
|
183
|
+
if (authors.length <= 2) return authors.join(' & ')
|
|
184
|
+
return `${authors[0]} et al.`
|
|
185
|
+
}
|
|
186
|
+
|
|
123
187
|
// Provide content ID for ProseImg/ProseVideo to resolve relative asset paths
|
|
124
188
|
provide('contentId', computed(() => member.value?._id || ''))
|
|
125
189
|
|
|
@@ -416,6 +480,92 @@ useHead({
|
|
|
416
480
|
box-shadow: var(--shadow-sm);
|
|
417
481
|
}
|
|
418
482
|
|
|
483
|
+
/* Publications Grid */
|
|
484
|
+
.publications-grid {
|
|
485
|
+
display: flex;
|
|
486
|
+
flex-direction: column;
|
|
487
|
+
gap: var(--spacing-sm);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.publication-card {
|
|
491
|
+
display: flex;
|
|
492
|
+
align-items: center;
|
|
493
|
+
justify-content: space-between;
|
|
494
|
+
gap: var(--spacing-md);
|
|
495
|
+
padding: var(--spacing-lg);
|
|
496
|
+
background: var(--color-bg);
|
|
497
|
+
border: 1px solid var(--color-border);
|
|
498
|
+
border-radius: var(--radius-lg);
|
|
499
|
+
text-decoration: none;
|
|
500
|
+
transition: all var(--transition-base);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.publication-card:hover {
|
|
504
|
+
background: var(--color-bg-alt);
|
|
505
|
+
border-color: var(--color-secondary);
|
|
506
|
+
transform: translateX(4px);
|
|
507
|
+
box-shadow: var(--shadow-md);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.publication-card__content {
|
|
511
|
+
flex: 1;
|
|
512
|
+
min-width: 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.publication-card__title {
|
|
516
|
+
font-family: var(--font-display);
|
|
517
|
+
font-size: 1rem;
|
|
518
|
+
font-weight: 600;
|
|
519
|
+
color: var(--color-primary);
|
|
520
|
+
margin: 0 0 var(--spacing-xs) 0;
|
|
521
|
+
line-height: 1.4;
|
|
522
|
+
display: -webkit-box;
|
|
523
|
+
-webkit-line-clamp: 2;
|
|
524
|
+
line-clamp: 2;
|
|
525
|
+
-webkit-box-orient: vertical;
|
|
526
|
+
overflow: hidden;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.publication-card__authors {
|
|
530
|
+
font-size: 0.875rem;
|
|
531
|
+
color: var(--color-text-muted);
|
|
532
|
+
margin: 0 0 var(--spacing-xs) 0;
|
|
533
|
+
line-height: 1.4;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.publication-card__meta {
|
|
537
|
+
display: flex;
|
|
538
|
+
align-items: center;
|
|
539
|
+
gap: var(--spacing-sm);
|
|
540
|
+
flex-wrap: wrap;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
.publication-card__venue {
|
|
544
|
+
font-size: 0.8125rem;
|
|
545
|
+
font-weight: 500;
|
|
546
|
+
color: var(--color-secondary);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.publication-card__year {
|
|
550
|
+
font-size: 0.8125rem;
|
|
551
|
+
font-weight: 600;
|
|
552
|
+
color: var(--color-accent);
|
|
553
|
+
background: rgba(0, 217, 255, 0.1);
|
|
554
|
+
padding: 2px 8px;
|
|
555
|
+
border-radius: var(--radius-sm);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.publication-card__arrow {
|
|
559
|
+
flex-shrink: 0;
|
|
560
|
+
color: var(--color-secondary);
|
|
561
|
+
transition: transform var(--transition-fast);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.publication-card:hover .publication-card__arrow {
|
|
565
|
+
transform: translateX(4px);
|
|
566
|
+
color: var(--color-accent);
|
|
567
|
+
}
|
|
568
|
+
|
|
419
569
|
/* ContentRenderer Markdown Styling */
|
|
420
570
|
.profile-section__body--content :deep(h2) {
|
|
421
571
|
font-family: var(--font-display);
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --loader tsx
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* markuxt-sync-publications
|
|
5
|
+
*
|
|
6
|
+
* GitHub Action to sync publications from OpenAlex based on member ORCIDs.
|
|
7
|
+
* Fetches publications for all members with ORCID, deduplicates against existing
|
|
8
|
+
* content, and writes new markdown files to <content_dir>/publications/<year>/<openalex_id>/index.md
|
|
9
|
+
*
|
|
10
|
+
* Usage: Called via GitHub Action (see action.yml) or directly:
|
|
11
|
+
* ROR_ID="https://ror.org/..." CONTACT_EMAIL="..." CONTENT_DIR="src" tsx index.ts
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
|
|
15
|
+
import { join } from 'path'
|
|
16
|
+
import { glob } from 'glob'
|
|
17
|
+
|
|
18
|
+
// Inputs from GitHub Actions (passed as environment variables)
|
|
19
|
+
const ROR_ID = process.env.INPUT_ROR_ID || ''
|
|
20
|
+
const CONTACT_EMAIL = process.env.INPUT_CONTACT_EMAIL || ''
|
|
21
|
+
const CONTENT_DIR = process.env.INPUT_CONTENT_DIR || 'src'
|
|
22
|
+
const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT || ''
|
|
23
|
+
|
|
24
|
+
if (!ROR_ID) {
|
|
25
|
+
console.error('Error: INPUT_ROR_ID is required')
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!CONTACT_EMAIL) {
|
|
30
|
+
console.error('Error: INPUT_CONTACT_EMAIL is required')
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OPENALEX_BASE = 'https://api.openalex.org'
|
|
35
|
+
const PUBLICATIONS_DIR = join(CONTENT_DIR, 'publications')
|
|
36
|
+
const MEMBERS_DIR = join(CONTENT_DIR, 'members')
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Types
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface ExistingPublication {
|
|
43
|
+
openalexId?: string
|
|
44
|
+
doi?: string
|
|
45
|
+
title?: string
|
|
46
|
+
year?: number
|
|
47
|
+
authors?: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PendingPublication {
|
|
51
|
+
openalexId: string
|
|
52
|
+
title: string
|
|
53
|
+
authors: string[]
|
|
54
|
+
authorsOrcid: (string | null)[]
|
|
55
|
+
year: number
|
|
56
|
+
doi: string | null
|
|
57
|
+
venue: string | null
|
|
58
|
+
keywords: string[]
|
|
59
|
+
abstract: string | null
|
|
60
|
+
hidden: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface MemberInfo {
|
|
64
|
+
name: string
|
|
65
|
+
orcid: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// YAML / Markdown helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function parseYamlFrontmatter(content: string): Record<string, unknown> {
|
|
73
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
74
|
+
if (!match) return {}
|
|
75
|
+
const result: Record<string, unknown> = {}
|
|
76
|
+
const lines = match[1].split('\n')
|
|
77
|
+
let currentKey = ''
|
|
78
|
+
let currentList: string[] | null = null
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const listItem = line.match(/^ - (.+)$/)
|
|
81
|
+
if (listItem && currentList !== null) {
|
|
82
|
+
currentList.push(listItem[1].trim().replace(/^["']|["']$/g, ''))
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (currentList !== null) {
|
|
86
|
+
result[currentKey] = currentList
|
|
87
|
+
currentList = null
|
|
88
|
+
}
|
|
89
|
+
const kv = line.match(/^(\w[\w_]*)\s*:\s*(.*)$/)
|
|
90
|
+
if (!kv) continue
|
|
91
|
+
const [, key, val] = kv
|
|
92
|
+
const trimmed = val.trim()
|
|
93
|
+
if (trimmed === '') {
|
|
94
|
+
currentKey = key
|
|
95
|
+
currentList = []
|
|
96
|
+
} else {
|
|
97
|
+
result[key] = trimmed.replace(/^["']|["']$/g, '')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (currentList !== null) result[currentKey] = currentList
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function yamlStr(value: string): string {
|
|
105
|
+
if (/[:#\[\]{}&*!,|>'"?%@`]/.test(value) || value.startsWith(' ') || value.endsWith(' ')) {
|
|
106
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
107
|
+
}
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Deduplication helpers
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
const STOP_WORDS = new Set([
|
|
116
|
+
'a','an','the','of','in','on','for','to','and','or','with','by','from',
|
|
117
|
+
'at','is','are','was','were','be','been','being','that','this','these',
|
|
118
|
+
'those','it','its'
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
function tokenize(title: string): Set<string> {
|
|
122
|
+
return new Set(
|
|
123
|
+
title.toLowerCase()
|
|
124
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
125
|
+
.split(/\s+/)
|
|
126
|
+
.filter(w => w.length > 1 && !STOP_WORDS.has(w))
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
131
|
+
if (a.size === 0 && b.size === 0) return 1
|
|
132
|
+
let intersection = 0
|
|
133
|
+
for (const w of a) if (b.has(w)) intersection++
|
|
134
|
+
const union = a.size + b.size - intersection
|
|
135
|
+
return union === 0 ? 0 : intersection / union
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function authorOverlap(a: string[], b: string[]): number {
|
|
139
|
+
if (!a.length || !b.length) return 0
|
|
140
|
+
const normalize = (name: string) => name.toLowerCase().replace(/[^a-z]/g, '')
|
|
141
|
+
const setA = new Set(a.map(normalize))
|
|
142
|
+
const setB = new Set(b.map(normalize))
|
|
143
|
+
let intersection = 0
|
|
144
|
+
for (const n of setA) if (setB.has(n)) intersection++
|
|
145
|
+
const union = setA.size + setB.size - intersection
|
|
146
|
+
return union === 0 ? 0 : intersection / union
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isDuplicate(
|
|
150
|
+
candidate: { title: string; year: number; authors: string[] },
|
|
151
|
+
existing: { title: string; year: number; authors: string[] }
|
|
152
|
+
): boolean {
|
|
153
|
+
if (candidate.title.toLowerCase().trim() === existing.title.toLowerCase().trim()) return true
|
|
154
|
+
if (Math.abs(candidate.year - existing.year) > 1) return false
|
|
155
|
+
const titleSim = jaccardSimilarity(tokenize(candidate.title), tokenize(existing.title))
|
|
156
|
+
const authorSim = authorOverlap(candidate.authors, existing.authors)
|
|
157
|
+
return titleSim >= 0.85 && authorSim >= 0.5
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Abstract reconstruction from inverted index
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function reconstructAbstract(invertedIndex: Record<string, number[]> | null): string | null {
|
|
165
|
+
if (!invertedIndex) return null
|
|
166
|
+
const entries: [string, number][] = []
|
|
167
|
+
for (const [word, positions] of Object.entries(invertedIndex)) {
|
|
168
|
+
for (const pos of positions) entries.push([word, pos])
|
|
169
|
+
}
|
|
170
|
+
entries.sort((a, b) => a[1] - b[1])
|
|
171
|
+
return entries.map(e => e[0]).join(' ')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Author name formatting: OpenAlex display_name → "LastName, FirstName"
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function formatAuthorName(displayName: string): string {
|
|
179
|
+
const parts = displayName.trim().split(/\s+/)
|
|
180
|
+
if (parts.length === 1) return parts[0]
|
|
181
|
+
const last = parts[parts.length - 1]
|
|
182
|
+
const first = parts.slice(0, -1).join(' ')
|
|
183
|
+
return `${last}, ${first}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractOrcidId(orcidUrl: string | null): string | null {
|
|
187
|
+
if (!orcidUrl) return null
|
|
188
|
+
const m = orcidUrl.match(/(\d{4}-\d{4}-\d{4}-\d{3}[\dX])/)
|
|
189
|
+
return m ? m[1] : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Markdown file builder
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function buildMarkdown(pub: PendingPublication): string {
|
|
197
|
+
const lines: string[] = ['---', `_hidden: ${pub.hidden}`]
|
|
198
|
+
lines.push(`title: ${yamlStr(pub.title)}`)
|
|
199
|
+
lines.push('authors:')
|
|
200
|
+
for (const a of pub.authors) lines.push(` - ${yamlStr(a)}`)
|
|
201
|
+
lines.push('authors_orcid:')
|
|
202
|
+
for (const o of pub.authorsOrcid) lines.push(` - ${o ?? 'null'}`)
|
|
203
|
+
lines.push(`year: ${pub.year}`)
|
|
204
|
+
lines.push(`doi: ${pub.doi ? yamlStr(pub.doi) : ''}`)
|
|
205
|
+
lines.push(`openalex_id: ${pub.openalexId}`)
|
|
206
|
+
lines.push(`venue: ${pub.venue ? yamlStr(pub.venue) : ''}`)
|
|
207
|
+
if (pub.keywords.length) {
|
|
208
|
+
lines.push('keywords:')
|
|
209
|
+
for (const k of pub.keywords) lines.push(` - ${yamlStr(k)}`)
|
|
210
|
+
} else {
|
|
211
|
+
lines.push('keywords: []')
|
|
212
|
+
}
|
|
213
|
+
lines.push('---', '')
|
|
214
|
+
if (pub.abstract) lines.push(pub.abstract, '')
|
|
215
|
+
return lines.join('\n')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// OpenAlex API helpers
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
async function oaFetch(path: string): Promise<unknown> {
|
|
223
|
+
const sep = path.includes('?') ? '&' : '?'
|
|
224
|
+
const url = `${OPENALEX_BASE}${path}${sep}mailto=${encodeURIComponent(CONTACT_EMAIL)}`
|
|
225
|
+
const res = await fetch(url, {
|
|
226
|
+
headers: { 'User-Agent': `markuxt-sync-publications/1.0 (mailto:${CONTACT_EMAIL})` }
|
|
227
|
+
})
|
|
228
|
+
if (!res.ok) throw new Error(`OpenAlex ${res.status}: ${url}`)
|
|
229
|
+
return res.json()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function getInstitutionId(rorId: string): Promise<string> {
|
|
233
|
+
const data = await oaFetch(
|
|
234
|
+
`/institutions?filter=ror:${encodeURIComponent(rorId)}&select=id`
|
|
235
|
+
) as { results: { id: string }[] }
|
|
236
|
+
if (!data.results?.length) throw new Error(`Institution not found for ROR: ${rorId}`)
|
|
237
|
+
return data.results[0].id
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function getAuthorId(orcid: string): Promise<string | null> {
|
|
241
|
+
const data = await oaFetch(
|
|
242
|
+
`/authors?filter=orcid:${encodeURIComponent(orcid)}&select=id`
|
|
243
|
+
) as { results: { id: string }[] }
|
|
244
|
+
return data.results?.[0]?.id ?? null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function getWorksForAuthor(authorId: string, institutionId: string): Promise<unknown[]> {
|
|
248
|
+
const works: unknown[] = []
|
|
249
|
+
let cursor = '*'
|
|
250
|
+
const fields = 'id,title,authorships,publication_year,doi,primary_location,keywords,abstract_inverted_index'
|
|
251
|
+
while (true) {
|
|
252
|
+
const data = await oaFetch(
|
|
253
|
+
`/works?filter=author.id:${encodeURIComponent(authorId)},institution.id:${encodeURIComponent(institutionId)}&per_page=200&cursor=${cursor}&select=${fields}`
|
|
254
|
+
) as { results: unknown[]; meta: { next_cursor: string | null } }
|
|
255
|
+
works.push(...data.results)
|
|
256
|
+
if (!data.meta?.next_cursor) break
|
|
257
|
+
cursor = data.meta.next_cursor
|
|
258
|
+
}
|
|
259
|
+
return works
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Scan existing publications
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async function scanExistingPublications(): Promise<ExistingPublication[]> {
|
|
267
|
+
const files = await glob('**/*.md', { cwd: PUBLICATIONS_DIR, absolute: true })
|
|
268
|
+
const existing: ExistingPublication[] = []
|
|
269
|
+
for (const file of files) {
|
|
270
|
+
const content = readFileSync(file, 'utf-8')
|
|
271
|
+
const fm = parseYamlFrontmatter(content)
|
|
272
|
+
if (fm._hidden === 'true' || fm._hidden === true) continue
|
|
273
|
+
const openalexId = typeof fm.openalex_id === 'string'
|
|
274
|
+
? fm.openalex_id.replace(/^W/, '') : undefined
|
|
275
|
+
const doi = typeof fm.doi === 'string' && fm.doi ? fm.doi : undefined
|
|
276
|
+
const title = typeof fm.title === 'string' ? fm.title : undefined
|
|
277
|
+
const year = typeof fm.year === 'string'
|
|
278
|
+
? parseInt(fm.year) : (typeof fm.year === 'number' ? fm.year : undefined)
|
|
279
|
+
const authors = Array.isArray(fm.authors) ? fm.authors as string[] : undefined
|
|
280
|
+
existing.push({ openalexId, doi, title, year, authors })
|
|
281
|
+
}
|
|
282
|
+
return existing
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Scan members with ORCID
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
async function scanMembersWithOrcid(): Promise<MemberInfo[]> {
|
|
290
|
+
const files = await glob('**/*.md', { cwd: MEMBERS_DIR, absolute: true })
|
|
291
|
+
const members: MemberInfo[] = []
|
|
292
|
+
for (const file of files) {
|
|
293
|
+
const content = readFileSync(file, 'utf-8')
|
|
294
|
+
const fm = parseYamlFrontmatter(content)
|
|
295
|
+
if (fm._hidden === 'true' || fm._hidden === true) continue
|
|
296
|
+
if (typeof fm.orcid === 'string' && fm.orcid.trim()) {
|
|
297
|
+
members.push({ name: String(fm.name ?? 'Unknown'), orcid: fm.orcid.trim() })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return members
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Parse OpenAlex work → PendingPublication
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
function parseWork(work: Record<string, unknown>): PendingPublication | null {
|
|
308
|
+
const rawId = typeof work.id === 'string'
|
|
309
|
+
? work.id.replace('https://openalex.org/', '') : null
|
|
310
|
+
if (!rawId) return null
|
|
311
|
+
|
|
312
|
+
const title = typeof work.title === 'string' ? work.title.trim() : null
|
|
313
|
+
if (!title) return null
|
|
314
|
+
|
|
315
|
+
const year = typeof work.publication_year === 'number' ? work.publication_year : null
|
|
316
|
+
if (!year) return null
|
|
317
|
+
|
|
318
|
+
const authorships = Array.isArray(work.authorships)
|
|
319
|
+
? work.authorships as Record<string, unknown>[] : []
|
|
320
|
+
|
|
321
|
+
const authors = authorships.map(a => {
|
|
322
|
+
const author = a.author as Record<string, unknown> | undefined
|
|
323
|
+
return author?.display_name ? formatAuthorName(String(author.display_name)) : null
|
|
324
|
+
}).filter((n): n is string => n !== null)
|
|
325
|
+
|
|
326
|
+
const authorsOrcid = authorships.map(a => {
|
|
327
|
+
const author = a.author as Record<string, unknown> | undefined
|
|
328
|
+
return extractOrcidId(author?.orcid ? String(author.orcid) : null)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const doiRaw = typeof work.doi === 'string' ? work.doi : null
|
|
332
|
+
const doi = doiRaw
|
|
333
|
+
? (doiRaw.startsWith('http') ? doiRaw : `https://doi.org/${doiRaw}`) : null
|
|
334
|
+
|
|
335
|
+
const primaryLocation = work.primary_location as Record<string, unknown> | undefined
|
|
336
|
+
const source = primaryLocation?.source as Record<string, unknown> | undefined
|
|
337
|
+
const venue = source?.display_name ? String(source.display_name) : null
|
|
338
|
+
|
|
339
|
+
const keywordsRaw = Array.isArray(work.keywords)
|
|
340
|
+
? work.keywords as Record<string, unknown>[] : []
|
|
341
|
+
const keywords = keywordsRaw.map(k => String(k.display_name ?? '')).filter(Boolean)
|
|
342
|
+
|
|
343
|
+
const abstract = reconstructAbstract(
|
|
344
|
+
work.abstract_inverted_index as Record<string, number[]> | null
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return { openalexId: rawId, title, authors, authorsOrcid, year, doi, venue, keywords, abstract, hidden: false }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Set GitHub Actions output
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
function setOutput(name: string, value: string): void {
|
|
355
|
+
if (GITHUB_OUTPUT) {
|
|
356
|
+
appendFileSync(GITHUB_OUTPUT, `${name}=${value}\n`)
|
|
357
|
+
}
|
|
358
|
+
console.log(`::set-output name=${name}::${value}`)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function appendFileSync(file: string, data: string): void {
|
|
362
|
+
const fd = require('fs').openSync(file, 'a')
|
|
363
|
+
require('fs').writeSync(fd, data)
|
|
364
|
+
require('fs').closeSync(fd)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Main
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
async function main() {
|
|
372
|
+
console.log(`[markuxt-sync-publications] Starting...`)
|
|
373
|
+
console.log(`[markuxt-sync-publications] ROR ID: ${ROR_ID}`)
|
|
374
|
+
console.log(`[markuxt-sync-publications] Content dir: ${CONTENT_DIR}`)
|
|
375
|
+
|
|
376
|
+
// 1. Resolve institution OpenAlex ID
|
|
377
|
+
const institutionId = await getInstitutionId(ROR_ID)
|
|
378
|
+
console.log(`[markuxt-sync-publications] Institution ID: ${institutionId}`)
|
|
379
|
+
|
|
380
|
+
// 2. Scan existing publications
|
|
381
|
+
const existing = await scanExistingPublications()
|
|
382
|
+
const existingOpenalexIds = new Set(
|
|
383
|
+
existing.map(p => p.openalexId).filter((id): id is string => !!id)
|
|
384
|
+
)
|
|
385
|
+
const existingDois = new Set(
|
|
386
|
+
existing
|
|
387
|
+
.map(p => p.doi?.toLowerCase().replace(/https?:\/\/doi\.org\//i, ''))
|
|
388
|
+
.filter((d): d is string => !!d)
|
|
389
|
+
)
|
|
390
|
+
console.log(`[markuxt-sync-publications] Found ${existing.length} existing publications`)
|
|
391
|
+
|
|
392
|
+
// 3. Scan members with ORCID
|
|
393
|
+
const members = await scanMembersWithOrcid()
|
|
394
|
+
console.log(`[markuxt-sync-publications] Found ${members.length} members with ORCID`)
|
|
395
|
+
|
|
396
|
+
// 4. Fetch works from OpenAlex for each member
|
|
397
|
+
const allWorks = new Map<string, PendingPublication>()
|
|
398
|
+
for (const member of members) {
|
|
399
|
+
console.log(`[markuxt-sync-publications] Processing ${member.name} (${member.orcid})...`)
|
|
400
|
+
const authorId = await getAuthorId(member.orcid)
|
|
401
|
+
if (!authorId) {
|
|
402
|
+
console.warn(` → Not found on OpenAlex: ${member.orcid}`)
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
console.log(` → Author ID: ${authorId}`)
|
|
406
|
+
const works = await getWorksForAuthor(authorId, institutionId)
|
|
407
|
+
console.log(` → ${works.length} works`)
|
|
408
|
+
for (const w of works) {
|
|
409
|
+
const pub = parseWork(w as Record<string, unknown>)
|
|
410
|
+
if (!pub) continue
|
|
411
|
+
if (!allWorks.has(pub.openalexId)) allWorks.set(pub.openalexId, pub)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
console.log(`[markuxt-sync-publications] Total unique works from OpenAlex: ${allWorks.size}`)
|
|
415
|
+
|
|
416
|
+
// 5. Filter out already-existing works
|
|
417
|
+
const pending: PendingPublication[] = []
|
|
418
|
+
for (const pub of allWorks.values()) {
|
|
419
|
+
const shortId = pub.openalexId.replace(/^W/, '')
|
|
420
|
+
if (existingOpenalexIds.has(shortId)) continue
|
|
421
|
+
const doiKey = pub.doi?.toLowerCase().replace(/https?:\/\/doi\.org\//i, '')
|
|
422
|
+
if (doiKey && existingDois.has(doiKey)) continue
|
|
423
|
+
const dupOfExisting = existing.some(e =>
|
|
424
|
+
e.title && e.year != null &&
|
|
425
|
+
isDuplicate(
|
|
426
|
+
{ title: pub.title, year: pub.year, authors: pub.authors },
|
|
427
|
+
{ title: e.title, year: e.year, authors: e.authors ?? [] }
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
if (dupOfExisting) continue
|
|
431
|
+
pending.push(pub)
|
|
432
|
+
}
|
|
433
|
+
console.log(`[markuxt-sync-publications] After dedup vs existing: ${pending.length} to add`)
|
|
434
|
+
|
|
435
|
+
// 6. Dedup within pending list, keep newest per group
|
|
436
|
+
const toWrite: PendingPublication[] = []
|
|
437
|
+
const consumed = new Set<number>()
|
|
438
|
+
for (let i = 0; i < pending.length; i++) {
|
|
439
|
+
if (consumed.has(i)) continue
|
|
440
|
+
const group: number[] = [i]
|
|
441
|
+
for (let j = i + 1; j < pending.length; j++) {
|
|
442
|
+
if (consumed.has(j)) continue
|
|
443
|
+
const a = pending[i], b = pending[j]
|
|
444
|
+
const sameDoi = !!(a.doi && b.doi && a.doi.toLowerCase() === b.doi.toLowerCase())
|
|
445
|
+
const sameTitle = a.title.toLowerCase().trim() === b.title.toLowerCase().trim()
|
|
446
|
+
const similar = isDuplicate(
|
|
447
|
+
{ title: a.title, year: a.year, authors: a.authors },
|
|
448
|
+
{ title: b.title, year: b.year, authors: b.authors }
|
|
449
|
+
)
|
|
450
|
+
if (sameDoi || sameTitle || similar) {
|
|
451
|
+
group.push(j)
|
|
452
|
+
consumed.add(j)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
consumed.add(i)
|
|
456
|
+
// Sort group: newest first; hide all but the first
|
|
457
|
+
group.sort((x, y) => pending[y].year - pending[x].year)
|
|
458
|
+
for (let k = 0; k < group.length; k++) {
|
|
459
|
+
toWrite.push({ ...pending[group[k]], hidden: k > 0 })
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const visible = toWrite.filter(p => !p.hidden).length
|
|
464
|
+
const hidden = toWrite.filter(p => p.hidden).length
|
|
465
|
+
console.log(`[markuxt-sync-publications] Writing ${toWrite.length} files (${visible} visible, ${hidden} hidden)`)
|
|
466
|
+
|
|
467
|
+
// 7. Write markdown files
|
|
468
|
+
const newFiles: string[] = []
|
|
469
|
+
for (const pub of toWrite) {
|
|
470
|
+
const dir = join(PUBLICATIONS_DIR, String(pub.year), pub.openalexId)
|
|
471
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
472
|
+
const filePath = join(dir, 'index.md')
|
|
473
|
+
writeFileSync(filePath, buildMarkdown(pub), 'utf-8')
|
|
474
|
+
console.log(` [${pub.hidden ? 'hidden' : 'visible'}] ${filePath}`)
|
|
475
|
+
newFiles.push(filePath)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 8. Set GitHub Actions outputs
|
|
479
|
+
setOutput('count', String(newFiles.length))
|
|
480
|
+
setOutput('files', newFiles.join('\n'))
|
|
481
|
+
|
|
482
|
+
console.log(`[markuxt-sync-publications] Done. Added ${newFiles.length} publication files.`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
main().catch(err => {
|
|
486
|
+
console.error('[markuxt-sync-publications] Fatal:', err)
|
|
487
|
+
process.exit(1)
|
|
488
|
+
})
|
|
@@ -29,6 +29,17 @@
|
|
|
29
29
|
>
|
|
30
30
|
<Google class="icon-inline" theme="outline" :size="20" fill="currentColor" :stroke-width="2.5" />
|
|
31
31
|
</a>
|
|
32
|
+
<a
|
|
33
|
+
v-if="member.orcid"
|
|
34
|
+
:href="`https://orcid.org/${member.orcid}`"
|
|
35
|
+
target="_blank"
|
|
36
|
+
rel="noopener"
|
|
37
|
+
class="member-card__action"
|
|
38
|
+
:aria-label="t('members.orcid')"
|
|
39
|
+
@click.stop
|
|
40
|
+
>
|
|
41
|
+
<Orcid class="icon-inline" theme="outline" :size="20" fill="currentColor" :stroke-width="2.5" />
|
|
42
|
+
</a>
|
|
32
43
|
</div>
|
|
33
44
|
</div>
|
|
34
45
|
<div class="member-card__content">
|
|
@@ -45,6 +56,7 @@
|
|
|
45
56
|
import { computed } from 'vue'
|
|
46
57
|
import Mail from '@icon-park/vue-next/es/icons/Mail'
|
|
47
58
|
import Google from '@icon-park/vue-next/es/icons/Google'
|
|
59
|
+
import Orcid from '@icon-park/vue-next/es/icons/IdCardH'
|
|
48
60
|
|
|
49
61
|
interface Member {
|
|
50
62
|
name: string
|
|
@@ -52,6 +64,7 @@ interface Member {
|
|
|
52
64
|
title?: string
|
|
53
65
|
email?: string
|
|
54
66
|
scholar?: string
|
|
67
|
+
orcid?: string
|
|
55
68
|
image?: string
|
|
56
69
|
interests?: string[]
|
|
57
70
|
category?: string
|