@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
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Markuxt
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
|
-
<img src="src/public/images/logo.png" alt="Markuxt" width="360" />
|
|
4
|
+
<img src="https://raw.githubusercontent.com/markuxt/markuxt/main/src/public/images/logo.png" alt="Markuxt" width="360" />
|
|
5
5
|
<!-- <h1>Markuxt</h1> -->
|
|
6
6
|
</div>
|
|
7
7
|
|
package/action.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: 'Sync Publications from OpenAlex'
|
|
2
|
+
description: 'Fetches publications from OpenAlex based on member ORCIDs and syncs to content directory'
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
ror_id:
|
|
6
|
+
description: 'ROR ID of the institution (e.g., https://ror.org/03y4dt428)'
|
|
7
|
+
required: true
|
|
8
|
+
contact_email:
|
|
9
|
+
description: 'Contact email for OpenAlex API requests'
|
|
10
|
+
required: true
|
|
11
|
+
content_dir:
|
|
12
|
+
description: 'Path to content directory (default: src)'
|
|
13
|
+
required: false
|
|
14
|
+
default: 'src'
|
|
15
|
+
|
|
16
|
+
outputs:
|
|
17
|
+
new_publications_count:
|
|
18
|
+
description: 'Number of new publication files added'
|
|
19
|
+
new_publications_files:
|
|
20
|
+
description: 'List of new publication file paths'
|
|
21
|
+
|
|
22
|
+
runs:
|
|
23
|
+
using: 'node20'
|
|
24
|
+
main: 'src/actions/sync-publications/index.ts'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markuxt/markuxt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "A Markdown-first academic portal framework for laboratories, research groups, and knowledge communities, powered by Nuxt.",
|
|
5
5
|
"author": "hnrobert",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"access": "public"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
+
"action.yml",
|
|
17
18
|
"nuxt.config.ts",
|
|
18
19
|
"app.config.ts",
|
|
19
20
|
"app.config.d.ts",
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"@icon-park/vue-next": "^1.4.2",
|
|
31
32
|
"@nuxt/content": "^2.13.2",
|
|
32
33
|
"@nuxtjs/i18n": "^10.4.0",
|
|
34
|
+
"glob": "^13.0.6",
|
|
33
35
|
"katex": "0.16.47",
|
|
34
36
|
"mermaid": "11.15.0",
|
|
35
37
|
"nuxt": "^3.15.1",
|
|
@@ -38,7 +40,11 @@
|
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
42
|
"@types/node": "^25.2.3",
|
|
43
|
+
"tsx": "^4.19.0",
|
|
41
44
|
"typescript": "^5.7.3",
|
|
42
45
|
"vue-tsc": "^3.2.4"
|
|
46
|
+
},
|
|
47
|
+
"bin": {
|
|
48
|
+
"markuxt-sync-publications": "./src/actions/sync-publications/index.ts"
|
|
43
49
|
}
|
|
44
50
|
}
|
|
@@ -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
|