@citestyle/ris 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Proximify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @citestyle/ris
2
+
3
+ RIS parser and serializer. Converts between RIS (Research Information Systems) tagged format and CSL-JSON, the standard input format for Citestyle.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @citestyle/ris
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Parse RIS to CSL-JSON
14
+
15
+ ```js
16
+ import { parseRis } from '@citestyle/ris'
17
+
18
+ const ris = `
19
+ TY - JOUR
20
+ AU - Smith, John
21
+ AU - Doe, Jane
22
+ TI - A Study of Climate Change
23
+ JF - Nature Climate Change
24
+ PY - 2024
25
+ VL - 14
26
+ SP - 100
27
+ EP - 108
28
+ DO - 10.1038/example
29
+ ER -
30
+ `
31
+
32
+ const items = parseRis(ris)
33
+ console.log(items[0].title) // "A Study of Climate Change"
34
+ console.log(items[0].author.length) // 2
35
+ console.log(items[0].page) // "100-108"
36
+ console.log(items[0].DOI) // "10.1038/example"
37
+ ```
38
+
39
+ ### Export CSL-JSON to RIS
40
+
41
+ ```js
42
+ import { exportRis } from '@citestyle/ris'
43
+
44
+ const items = [{
45
+ id: '1',
46
+ type: 'article-journal',
47
+ title: 'A Study of Climate Change',
48
+ author: [
49
+ { family: 'Smith', given: 'John' },
50
+ { family: 'Doe', given: 'Jane' },
51
+ ],
52
+ issued: { 'date-parts': [[2024]] },
53
+ 'container-title': 'Nature Climate Change',
54
+ volume: '14',
55
+ page: '100-108',
56
+ DOI: '10.1038/example',
57
+ }]
58
+
59
+ const output = exportRis(items)
60
+ // TY - JOUR
61
+ // AU - Smith, John
62
+ // AU - Doe, Jane
63
+ // TI - A Study of Climate Change
64
+ // ...
65
+ // ER -
66
+ ```
67
+
68
+ ### Pipeline: RIS to formatted bibliography
69
+
70
+ ```js
71
+ import { parseRis } from '@citestyle/ris'
72
+ import { createRegistry } from '@citestyle/registry'
73
+ import * as apa from '@citestyle/styles/apa'
74
+
75
+ const ris = readFileSync('export.ris', 'utf-8')
76
+ const items = parseRis(ris)
77
+
78
+ const registry = createRegistry(apa)
79
+ registry.addItems(items)
80
+
81
+ const bibliography = registry.getBibliography()
82
+ bibliography.forEach(entry => console.log(entry.html))
83
+ ```
84
+
85
+ ## API
86
+
87
+ ### `parseRis(str)`
88
+
89
+ Parse a RIS string into an array of CSL-JSON items.
90
+
91
+ **Features:**
92
+ - 30+ RIS type codes (JOUR, BOOK, CHAP, THES, CONF, RPRT, etc.)
93
+ - Repeatable tags (AU for multiple authors, KW for keywords)
94
+ - SP + EP page merging into a single page range
95
+ - Date parsing from PY and DA tags
96
+ - Editor (A2/ED), translator, and other contributor roles
97
+ - Common in exports from PubMed, Scopus, Web of Science, and Zotero
98
+
99
+ ### `exportRis(items)`
100
+
101
+ Serialize an array of CSL-JSON items to a RIS string. Each item is delimited by `TY` (type) and `ER` (end of record) tags.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@citestyle/ris",
3
+ "version": "0.1.0",
4
+ "description": "RIS ↔ CSL-JSON parser and serializer",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types.d.ts",
9
+ "import": "./src/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "types.d.ts",
15
+ "README.md"
16
+ ],
17
+ "dependencies": {
18
+ "@citestyle/types": "0.0.2"
19
+ },
20
+ "devDependencies": {
21
+ "vitest": "^3.1.1"
22
+ },
23
+ "engines": {
24
+ "node": ">=20.19.0"
25
+ },
26
+ "license": "MIT",
27
+ "scripts": {
28
+ "test": "vitest run --passWithNoTests",
29
+ "lint": "echo 'TODO: configure linter'"
30
+ }
31
+ }
package/src/export.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Serialize CSL-JSON items as a RIS string.
3
+ *
4
+ * @param {object[]} items - CSL-JSON items
5
+ * @returns {string} RIS source
6
+ */
7
+
8
+ // ── CSL type → RIS type mapping ────────────────────────────────────────────
9
+
10
+ const REVERSE_TYPE_MAP = {
11
+ 'article-journal': 'JOUR',
12
+ 'article-magazine': 'MGZN',
13
+ 'article-newspaper': 'NEWS',
14
+ 'article': 'GEN',
15
+ 'book': 'BOOK',
16
+ 'chapter': 'CHAP',
17
+ 'dataset': 'DATA',
18
+ 'graphic': 'ART',
19
+ 'legal_case': 'CASE',
20
+ 'legislation': 'STAT',
21
+ 'manuscript': 'UNPB',
22
+ 'map': 'MAP',
23
+ 'motion_picture': 'MPCT',
24
+ 'pamphlet': 'PAMP',
25
+ 'paper-conference': 'CONF',
26
+ 'patent': 'PAT',
27
+ 'personal_communication': 'PCOMM',
28
+ 'report': 'RPRT',
29
+ 'software': 'COMP',
30
+ 'song': 'MUSIC',
31
+ 'thesis': 'THES',
32
+ 'webpage': 'ELEC',
33
+ }
34
+
35
+ // ── Name serialization ──────────────────────────────────────────────────────
36
+
37
+ function serializeName(name) {
38
+ if (name.literal) return name.literal
39
+ const parts = []
40
+ const particle = name['non-dropping-particle'] || name['dropping-particle'] || ''
41
+ const family = (particle ? particle + ' ' : '') + (name.family || '')
42
+ parts.push(family)
43
+ if (name.given) parts.push(name.given)
44
+ if (name.suffix) parts.push(name.suffix)
45
+ return parts.join(', ')
46
+ }
47
+
48
+ // ── Public API ──────────────────────────────────────────────────────────────
49
+
50
+ export function exportRis(items) {
51
+ if (!items || !items.length) return ''
52
+
53
+ return items.map(item => {
54
+ const lines = []
55
+ const tag = (t, v) => { if (v != null && v !== '') lines.push(`${t} - ${v}`) }
56
+
57
+ // Type
58
+ tag('TY', REVERSE_TYPE_MAP[item.type] || 'GEN')
59
+
60
+ // ID
61
+ tag('ID', item.id)
62
+
63
+ // Authors
64
+ if (item.author) item.author.forEach(a => tag('AU', serializeName(a)))
65
+
66
+ // Editors
67
+ if (item.editor) item.editor.forEach(e => tag('A2', serializeName(e)))
68
+
69
+ // Title
70
+ tag('TI', item.title)
71
+
72
+ // Container title
73
+ if (item['container-title']) {
74
+ const ct = item.type === 'article-journal' ? 'JO' : 'T2'
75
+ tag(ct, item['container-title'])
76
+ }
77
+
78
+ // Abstract
79
+ tag('AB', item.abstract)
80
+
81
+ // Date
82
+ const dateParts = item.issued?.['date-parts']?.[0]
83
+ if (dateParts) {
84
+ const parts = [String(dateParts[0])]
85
+ if (dateParts[1]) parts.push(String(dateParts[1]).padStart(2, '0'))
86
+ if (dateParts[2]) parts.push(String(dateParts[2]).padStart(2, '0'))
87
+ tag('PY', parts.join('/'))
88
+ }
89
+
90
+ // Volume, issue
91
+ tag('VL', item.volume)
92
+ tag('IS', item.issue)
93
+
94
+ // Pages
95
+ if (item.page) {
96
+ const pageParts = item.page.split(/[–-]/)
97
+ tag('SP', pageParts[0].trim())
98
+ if (pageParts[1]) tag('EP', pageParts[1].trim())
99
+ }
100
+
101
+ // Identifiers and links
102
+ tag('DO', item.DOI)
103
+ tag('UR', item.URL)
104
+ tag('SN', item.ISSN || item.ISBN)
105
+
106
+ // Publisher
107
+ tag('PB', item.publisher)
108
+ tag('CY', item['publisher-place'])
109
+
110
+ // Other
111
+ tag('ET', item.edition)
112
+ tag('LA', item.language)
113
+ if (item['collection-title']) tag('T3', item['collection-title'])
114
+
115
+ // Keywords
116
+ if (item.keyword) {
117
+ item.keyword.split(/,\s*/).forEach(kw => tag('KW', kw.trim()))
118
+ }
119
+
120
+ // Notes
121
+ tag('N1', item.note)
122
+
123
+ // End record
124
+ lines.push('ER - ')
125
+ return lines.join('\n')
126
+ }).join('\n') + '\n'
127
+ }
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @citestyle/ris
3
+ *
4
+ * RIS ↔ CSL-JSON conversion.
5
+ */
6
+
7
+ export { parseRis } from './parse.js'
8
+ export { exportRis } from './export.js'
package/src/parse.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Parse a RIS string into an array of CSL-JSON items.
3
+ *
4
+ * RIS is a tagged format with TY (type) / ER (end) delimiters.
5
+ * Each tag is 2 uppercase letters followed by " - " and a value.
6
+ * Common in database exports (PubMed, Scopus, Web of Science).
7
+ *
8
+ * @param {string} ris - RIS source
9
+ * @returns {object[]} Array of CSL-JSON items
10
+ */
11
+
12
+ // ── RIS type → CSL type mapping ────────────────────────────────────────────
13
+
14
+ const TYPE_MAP = {
15
+ ABST: 'article',
16
+ ADVS: 'motion_picture',
17
+ ART: 'graphic',
18
+ BILL: 'bill',
19
+ BOOK: 'book',
20
+ CASE: 'legal_case',
21
+ CHAP: 'chapter',
22
+ COMP: 'software',
23
+ CONF: 'paper-conference',
24
+ CTLG: 'book',
25
+ DATA: 'dataset',
26
+ EDBOOK: 'book',
27
+ EJOUR: 'article-journal',
28
+ ELEC: 'webpage',
29
+ GEN: 'article',
30
+ HEAR: 'legal_case',
31
+ ICOMM: 'personal_communication',
32
+ INPR: 'article-journal',
33
+ JOUR: 'article-journal',
34
+ JFULL: 'article-journal',
35
+ MAP: 'map',
36
+ MGZN: 'article-magazine',
37
+ MPCT: 'motion_picture',
38
+ MUSIC: 'song',
39
+ NEWS: 'article-newspaper',
40
+ PAMP: 'pamphlet',
41
+ PAT: 'patent',
42
+ PCOMM: 'personal_communication',
43
+ RPRT: 'report',
44
+ SER: 'article',
45
+ SLIDE: 'graphic',
46
+ SOUND: 'song',
47
+ STAND: 'legislation',
48
+ STAT: 'legislation',
49
+ THES: 'thesis',
50
+ UNPB: 'manuscript',
51
+ VIDEO: 'motion_picture',
52
+ }
53
+
54
+ // ── Name parsing ────────────────────────────────────────────────────────────
55
+
56
+ function parseName(str) {
57
+ if (!str) return null
58
+ str = str.trim()
59
+
60
+ // "Last, First Suffix" or "Last, First, Suffix"
61
+ const parts = str.split(',').map(s => s.trim())
62
+ if (parts.length >= 2) {
63
+ const result = { family: parts[0] }
64
+ // Check if third part is a suffix
65
+ if (parts.length >= 3) {
66
+ result.given = parts[1]
67
+ result.suffix = parts[2]
68
+ } else {
69
+ result.given = parts[1]
70
+ }
71
+ return result
72
+ }
73
+
74
+ // Single name (corporate author)
75
+ return { literal: str }
76
+ }
77
+
78
+ // ── Date parsing ────────────────────────────────────────────────────────────
79
+
80
+ function parseDate(str) {
81
+ if (!str) return null
82
+ str = str.trim()
83
+
84
+ // Common formats: YYYY, YYYY/MM/DD, YYYY/MM, YYYY/MM/DD/other
85
+ const parts = str.split('/')
86
+ const year = parseInt(parts[0], 10)
87
+ if (isNaN(year)) return null
88
+
89
+ const dateParts = [year]
90
+ if (parts[1]) {
91
+ const month = parseInt(parts[1], 10)
92
+ if (!isNaN(month) && month >= 1 && month <= 12) {
93
+ dateParts.push(month)
94
+ if (parts[2]) {
95
+ const day = parseInt(parts[2], 10)
96
+ if (!isNaN(day) && day >= 1 && day <= 31) dateParts.push(day)
97
+ }
98
+ }
99
+ }
100
+ return { 'date-parts': [dateParts] }
101
+ }
102
+
103
+ // ── Public API ──────────────────────────────────────────────────────────────
104
+
105
+ export function parseRis(ris) {
106
+ if (!ris || typeof ris !== 'string') return []
107
+
108
+ const items = []
109
+ let current = null
110
+
111
+ const lines = ris.split(/\r?\n/)
112
+
113
+ for (const line of lines) {
114
+ // RIS tag format: XX - value (2 chars, 2 spaces, dash, space, value)
115
+ const match = line.match(/^([A-Z][A-Z0-9])\s{2}-\s?(.*)$/)
116
+ if (!match) continue
117
+
118
+ const [, tag, value] = match
119
+ const val = value.trim()
120
+
121
+ if (tag === 'TY') {
122
+ current = { type: TYPE_MAP[val] || 'article', _raw: {} }
123
+ continue
124
+ }
125
+
126
+ if (tag === 'ER') {
127
+ if (current) {
128
+ items.push(finalizeItem(current))
129
+ current = null
130
+ }
131
+ continue
132
+ }
133
+
134
+ if (!current) continue
135
+
136
+ // Accumulate tags (some are repeatable)
137
+ if (!current._raw[tag]) current._raw[tag] = []
138
+ current._raw[tag].push(val)
139
+ }
140
+
141
+ // Handle unterminated record
142
+ if (current) items.push(finalizeItem(current))
143
+
144
+ return items
145
+ }
146
+
147
+ function finalizeItem(record) {
148
+ const raw = record._raw
149
+ const item = { type: record.type }
150
+
151
+ // ID
152
+ item.id = first(raw.ID) || first(raw.AN) || `ris-${Date.now()}`
153
+
154
+ // Authors (AU/A1 are primary authors)
155
+ const authors = [...(raw.AU || []), ...(raw.A1 || [])]
156
+ if (authors.length) item.author = authors.map(parseName).filter(Boolean)
157
+
158
+ // Editors (A2/ED)
159
+ const editors = [...(raw.A2 || []), ...(raw.ED || [])]
160
+ if (editors.length) item.editor = editors.map(parseName).filter(Boolean)
161
+
162
+ // Title
163
+ item.title = first(raw.TI) || first(raw.T1) || ''
164
+
165
+ // Container title
166
+ const container = first(raw.T2) || first(raw.JO) || first(raw.JF) || first(raw.JA) || first(raw.J2)
167
+ if (container) item['container-title'] = container
168
+
169
+ // Abstract
170
+ const abstract = first(raw.AB) || first(raw.N2)
171
+ if (abstract) item.abstract = abstract
172
+
173
+ // Date
174
+ const dateStr = first(raw.PY) || first(raw.Y1) || first(raw.DA)
175
+ if (dateStr) {
176
+ const d = parseDate(dateStr)
177
+ if (d) item.issued = d
178
+ }
179
+
180
+ // Simple fields
181
+ if (first(raw.VL)) item.volume = first(raw.VL)
182
+ if (first(raw.IS)) item.issue = first(raw.IS)
183
+
184
+ // Pages: SP (start) + EP (end)
185
+ const sp = first(raw.SP)
186
+ const ep = first(raw.EP)
187
+ if (sp && ep) item.page = `${sp}–${ep}`
188
+ else if (sp) item.page = sp
189
+
190
+ if (first(raw.DO)) item.DOI = first(raw.DO)
191
+ if (first(raw.UR)) item.URL = first(raw.UR)
192
+ if (first(raw.SN)) item.ISSN = first(raw.SN)
193
+ if (first(raw.PB)) item.publisher = first(raw.PB)
194
+ if (first(raw.CY)) item['publisher-place'] = first(raw.CY)
195
+ if (first(raw.ET)) item.edition = first(raw.ET)
196
+ if (first(raw.LA)) item.language = first(raw.LA)
197
+
198
+ // Keywords (repeatable)
199
+ if (raw.KW && raw.KW.length) item.keyword = raw.KW.join(', ')
200
+
201
+ // Notes
202
+ const notes = [...(raw.N1 || []), ...(raw.RN || [])]
203
+ if (notes.length) item.note = notes.join('; ')
204
+
205
+ // Series
206
+ if (first(raw.T3)) item['collection-title'] = first(raw.T3)
207
+
208
+ return item
209
+ }
210
+
211
+ function first(arr) {
212
+ return arr && arr.length ? arr[0] : null
213
+ }
package/types.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { parseRis, exportRis } from '@citestyle/types'