bibtex-reference-manager-mcp 0.0.0-stage → 0.1.1
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +226 -2
- package/build/src/analyze.js +169 -0
- package/build/src/bib.js +604 -0
- package/build/src/core/tools.js +25 -0
- package/build/src/index.js +25 -0
- package/build/src/server.js +9 -0
- package/build/src/tools.js +180 -0
- package/llms.txt +206 -0
- package/package.json +102 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- The README now has a Common workflows section showing how the five tools work together in a typical thesis or cleaning workflow.
|
|
6
|
+
|
|
7
|
+
- The README now has a competitor comparison table showing how it differs from servers that connect to online accounts or web APIs.
|
|
8
|
+
|
|
9
|
+
- The README and llms.txt now have a Why this server section and steps for exporting a Zotero or Mendeley library to .bib or RIS and pasting it in.
|
|
10
|
+
|
|
11
|
+
- The README intro, the npm description and the npm keywords now use the words students search for, such as LaTeX, Zotero export, Mendeley export, EndNote RIS, thesis bibliography and fix .bib file.
|
|
12
|
+
|
|
13
|
+
- BibTeX and RIS parse errors now name the entry, the field and the line that failed, for example an unclosed brace, an unclosed quote, an entry that is never closed, or a RIS record without a TY line. Before, the parser guessed and returned a damaged entry.
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
- First release of BibTeX Reference Manager MCP.
|
|
18
|
+
- Added `parse_bibliography`, which reads BibTeX, RIS or CSL JSON and returns normalized entries.
|
|
19
|
+
- Added `convert_bibliography`, which converts between BibTeX, RIS, CSL JSON and plain text.
|
|
20
|
+
- Added `dedupe_entries`, which finds duplicates by DOI, cite key and title similarity.
|
|
21
|
+
- Added `lint_entries`, which flags missing fields, bad page ranges, uncapitalized titles and invalid DOIs.
|
|
22
|
+
- Added `generate_cite_keys`, which makes keys like smith2024deep.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AnonymousOnyx22
|
|
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
CHANGED
|
@@ -1,3 +1,227 @@
|
|
|
1
|
-
#
|
|
1
|
+
# BibTeX Reference Manager MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/bibtex-reference-manager-mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/bibtex-reference-manager-mcp)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
BibTeX Reference Manager MCP is a free, open source [MCP](https://modelcontextprotocol.io) server, published on npm as `bibtex-reference-manager-mcp`. It cleans, converts, dedupes and formats BibTeX, RIS and CSL JSON reference lists, so you stop fixing broken `.bib` files by hand. Paste a list into your AI app and ask it to read the entries, convert them to another format, find the duplicates, flag the problems or give every entry a tidy cite key.
|
|
8
|
+
|
|
9
|
+
It is made with students in mind. Writing a thesis in LaTeX? Ask it to fix a broken `.bib` file, clean up a Zotero export or a Mendeley export, turn an EndNote RIS file into BibTeX, and tidy your thesis bibliography before you hand it in.
|
|
10
|
+
|
|
11
|
+
Use it with Claude Desktop, Claude Code, Cursor, VS Code Copilot and any other app that supports MCP. It runs on your computer, needs no internet and no login, and your references never leave your machine.
|
|
12
|
+
|
|
13
|
+
Free forever. No account and no subscription.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Run it straight from npm with `npx`. You need Node.js 20 or newer, see [Node version](#node-version) for the details. There is nothing else to install, no account, no API key and no config file for the server itself.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx -y bibtex-reference-manager-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Try these prompts
|
|
24
|
+
|
|
25
|
+
Paste your reference list into the chat first, or attach the file, then ask:
|
|
26
|
+
|
|
27
|
+
1. "Here is my references.bib. Lint it and tell me what is broken, entry by entry."
|
|
28
|
+
2. "Find the duplicates in this list and give me back a clean BibTeX file without them."
|
|
29
|
+
3. "Convert these RIS entries from my library export to BibTeX, then rename the keys so they look like smith2024deep."
|
|
30
|
+
|
|
31
|
+
Add it to your AI app's MCP config so the app can start it for you:
|
|
32
|
+
|
|
33
|
+
- [Claude Desktop](#claude-desktop)
|
|
34
|
+
- [Claude Code](#claude-code)
|
|
35
|
+
- [Cursor](#cursor)
|
|
36
|
+
- [VS Code Copilot](#vs-code-copilot)
|
|
37
|
+
|
|
38
|
+
All of them start the same server the same way. The only difference is where the config goes.
|
|
39
|
+
|
|
40
|
+
### Claude Desktop
|
|
41
|
+
|
|
42
|
+
Add this to `claude_desktop_config.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"bibtex": {
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["-y", "bibtex-reference-manager-mcp"]
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Restart Claude Desktop after you save the file.
|
|
56
|
+
|
|
57
|
+
### Claude Code
|
|
58
|
+
|
|
59
|
+
Run this in a terminal:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
claude mcp add bibtex -- npx -y bibtex-reference-manager-mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Cursor
|
|
66
|
+
|
|
67
|
+
Cursor reads `.cursor/mcp.json` from the project folder, or `~/.cursor/mcp.json` in your home folder if you want the server in every project.
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"bibtex": {
|
|
73
|
+
"type": "stdio",
|
|
74
|
+
"command": "npx",
|
|
75
|
+
"args": ["-y", "bibtex-reference-manager-mcp"]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### VS Code Copilot
|
|
82
|
+
|
|
83
|
+
Add this to `.vscode/mcp.json` in your workspace:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"servers": {
|
|
88
|
+
"bibtex": {
|
|
89
|
+
"type": "stdio",
|
|
90
|
+
"command": "npx",
|
|
91
|
+
"args": ["-y", "bibtex-reference-manager-mcp"]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Why this server
|
|
98
|
+
|
|
99
|
+
Some MCP servers connect to a reference manager account and read your library online. This one does not. It works on text you paste in, so it is a different tool for a different job.
|
|
100
|
+
|
|
101
|
+
- It needs no account, no API key and no internet. Nothing is sent anywhere.
|
|
102
|
+
- It does not connect to Zotero or Mendeley. You export a file and paste it in, so it works with any reference manager that can write BibTeX, RIS or CSL JSON.
|
|
103
|
+
- The work is done by code, not by the AI guessing. Converting, deduping, linting and renaming keys give the same result every time.
|
|
104
|
+
- It is read only. It never edits your library or your files. You get the result back and decide what to keep.
|
|
105
|
+
|
|
106
|
+
If you want an assistant to search or change a live online library, you need a server that connects to that service. This one is for cleaning up an exported list.
|
|
107
|
+
|
|
108
|
+
## Competitor comparison
|
|
109
|
+
|
|
110
|
+
Other MCP servers work with reference libraries too. Most connect to an online account or a web API, so your data leaves your computer and you need an account or an API key. This server works on text you paste in, so it needs no account, no API key and no internet, and nothing is sent anywhere.
|
|
111
|
+
|
|
112
|
+
| Server | Works offline | Needs account or key | Reads a live library | Read only | Formats |
|
|
113
|
+
| --- | --- | --- | --- | --- | --- |
|
|
114
|
+
| BibTeX Reference Manager MCP | Yes | No | No | Yes | BibTeX, RIS, CSL JSON |
|
|
115
|
+
| zotero-mcp | No | Yes | Yes | No | Zotero, BibTeX |
|
|
116
|
+
| mendeley-mcp | No | Yes | Yes | No | Mendeley, BibTeX |
|
|
117
|
+
| bibtex-mcp | No | Optional | No | Yes | BibTeX |
|
|
118
|
+
| reference-mcp | No | No | No | Yes | BibTeX |
|
|
119
|
+
| CrossRef-MCP | No | No | No | Yes | BibTeX |
|
|
120
|
+
| mcp_doi2bib | No | No | No | Yes | BibTeX |
|
|
121
|
+
| Local-Zotero-MCP | No | No | Yes | Yes | Zotero |
|
|
122
|
+
| jabref-mcp | No | No | Yes | No | BibTeX |
|
|
123
|
+
|
|
124
|
+
zotero-mcp and mendeley-mcp connect to your online Zotero or Mendeley library and can change it. bibtex-mcp, reference-mcp, CrossRef-MCP and mcp_doi2bib fetch citations from web sources such as Semantic Scholar, OpenAlex, CiteAs, Google Scholar and CrossRef. Local-Zotero-MCP reads your local Zotero library through the Zotero desktop app. jabref-mcp reads a JabRef .bib file and can add entries through JabRef.
|
|
125
|
+
|
|
126
|
+
Pick this server when you want to clean, convert, dedupe, lint and rename an exported list without sending it anywhere. Pick one of the others when you want to search or change a live online library.
|
|
127
|
+
|
|
128
|
+
## Export from Zotero or Mendeley
|
|
129
|
+
|
|
130
|
+
1. In Zotero, right click a collection (or select some items), choose the export option and pick BibTeX or RIS. In Mendeley, select your references, choose Export and pick BibTeX or RIS. Menu names change between versions, so look for Export.
|
|
131
|
+
2. Open the saved `.bib` or `.ris` file in any text editor and copy everything, or attach the file in an app that accepts attachments.
|
|
132
|
+
3. Paste it into your AI app and ask, for example: "Lint this list, remove the duplicates and give me back clean BibTeX with tidy cite keys."
|
|
133
|
+
4. Copy the result into your `.bib` file. To get it back into your reference manager, save it as a `.bib` or `.ris` file and import it.
|
|
134
|
+
|
|
135
|
+
The format is detected for you. If a file is not recognized, say which format it is.
|
|
136
|
+
|
|
137
|
+
## Tools
|
|
138
|
+
|
|
139
|
+
| Tool | What it does |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| `parse_bibliography` | Reads BibTeX, RIS or CSL JSON text and returns normalized entries: lower case field names, tidy spacing, bare DOIs, the year filled in from a date, and authors split into "Family, Given". |
|
|
142
|
+
| `convert_bibliography` | Converts between BibTeX, RIS, CSL JSON and plain text. The input format is detected unless you pass `from`. Page ranges become `12--15` in BibTeX. |
|
|
143
|
+
| `dedupe_entries` | Finds duplicates by DOI, by the same cite key and by similar titles (the years must not clash). Says which entry to keep and returns a BibTeX list without the extra copies. |
|
|
144
|
+
| `lint_entries` | Flags missing required fields, bad page ranges, uncapitalized titles, invalid DOIs, odd years and repeated keys. |
|
|
145
|
+
| `generate_cite_keys` | Makes keys from the first author, the year and the first useful title word, like `smith2024deep`. Repeats get a letter, like `smith2024deepa`. |
|
|
146
|
+
|
|
147
|
+
Every tool takes the reference list as `text`, and every tool returns both a readable answer and structured fields. All of them are read only. They never change a file, because you paste the text in and get the result back.
|
|
148
|
+
|
|
149
|
+
### Formats
|
|
150
|
+
|
|
151
|
+
- BibTeX: `@string` macros, quoted and braced values, `#` joins, and `@comment` blocks are handled.
|
|
152
|
+
- RIS: the usual tags such as `TY`, `AU`, `TI`, `T2`, `PY`, `SP`, `EP`, `DO` and `KW`.
|
|
153
|
+
- CSL JSON: a list of items, one item, or an object with an `items` list.
|
|
154
|
+
|
|
155
|
+
## Common workflows
|
|
156
|
+
|
|
157
|
+
These show how the five tools fit together. All of them take the reference list as `text` and are read only.
|
|
158
|
+
|
|
159
|
+
### Clean a Zotero or Mendeley export
|
|
160
|
+
|
|
161
|
+
1. Run `parse_bibliography` to normalize the entries and fill in missing years.
|
|
162
|
+
2. Run `dedupe_entries` to remove duplicates by DOI, cite key or title.
|
|
163
|
+
3. Run `lint_entries` to see missing fields, bad page ranges and odd years.
|
|
164
|
+
4. Run `generate_cite_keys` to give every entry a tidy key like `smith2024deep`.
|
|
165
|
+
5. Run `convert_bibliography` to get back a clean BibTeX file.
|
|
166
|
+
|
|
167
|
+
### Turn an EndNote RIS export into BibTeX
|
|
168
|
+
|
|
169
|
+
1. Run `parse_bibliography` on the RIS text to check it parses.
|
|
170
|
+
2. Run `convert_bibliography` with `to` set to BibTeX.
|
|
171
|
+
3. Run `generate_cite_keys` if you want keys like `smith2024deep`.
|
|
172
|
+
|
|
173
|
+
### Check a finished .bib before you hand it in
|
|
174
|
+
|
|
175
|
+
1. Run `parse_bibliography` to normalize.
|
|
176
|
+
2. Run `lint_entries` and fix what it flags.
|
|
177
|
+
3. Run `dedupe_entries` to make sure nothing is listed twice.
|
|
178
|
+
|
|
179
|
+
## Privacy
|
|
180
|
+
|
|
181
|
+
Everything runs on your computer. The server reads only the text your AI app passes to it. It makes no network requests, does not look up DOIs online, needs no API key and keeps nothing between calls. DOI checks only look at the shape of the DOI, they do not ask a website whether it exists.
|
|
182
|
+
|
|
183
|
+
## FAQ
|
|
184
|
+
|
|
185
|
+
**Does it check that a DOI really exists?** No. It runs offline, so it only checks that the DOI has the right shape, like `10.1000/xyz123`.
|
|
186
|
+
|
|
187
|
+
**How does it decide two titles are the same?** It lowercases the titles, drops punctuation and accents, and compares pairs of letters. The default match is 0.9 out of 1. Pass `title_threshold` to loosen or tighten that. Two entries with different years are not matched by title.
|
|
188
|
+
|
|
189
|
+
**Will it change my protected capitals like `{DNA}`?** No. Braces are kept in BibTeX output and only removed when you convert to RIS, CSL JSON or plain text.
|
|
190
|
+
|
|
191
|
+
## Node version
|
|
192
|
+
|
|
193
|
+
You need Node.js 20 or newer to run the server. To work on it, use Node.js 22.12 or newer.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
npm install
|
|
199
|
+
npm run build
|
|
200
|
+
npm test
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The code is in `src`. `src/bib.ts` reads and writes the formats, `src/analyze.ts` finds duplicates and lint problems, and `src/tools.ts` defines the five tools.
|
|
204
|
+
|
|
205
|
+
## More free student MCP servers
|
|
206
|
+
|
|
207
|
+
All free, open source and read only where they touch your accounts. Browse the full list at [brightspacemcp.com/more-mcps](https://brightspacemcp.com/more-mcps/).
|
|
208
|
+
|
|
209
|
+
| Server | What it does |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| [Brightspace MCP](https://github.com/AnonymousOnyx22/brightspace-d2l-mcp) | D2L Brightspace for AI. Due dates with real submission status, grades, a target grade calculator and course files. |
|
|
212
|
+
| [Canvas MCP](https://github.com/AnonymousOnyx22/canvas-lms-student-mcp) | Canvas LMS for AI, read only. Due dates, missing work, grades and announcements. |
|
|
213
|
+
| [Moodle MCP](https://github.com/AnonymousOnyx22/moodle-student-mcp) | Moodle for AI, read only. Due dates, overdue work, grades and announcements. |
|
|
214
|
+
| [Google Classroom MCP](https://github.com/AnonymousOnyx22/google-classroom-student-mcp) | Google Classroom for AI, read only. Todos, due dates, missing work and grades. |
|
|
215
|
+
| [GPA Calculator MCP](https://github.com/AnonymousOnyx22/gpa-calculator-mcp) | GPA, grade conversion and the grade you need on the final, for AI. Works offline, no account. |
|
|
216
|
+
| [Citations MCP](https://github.com/AnonymousOnyx22/student-citations-mcp) | APA, MLA and Chicago citations from a DOI, an ISBN or typed details. No account. |
|
|
217
|
+
| [Study Planner MCP](https://github.com/AnonymousOnyx22/study-planner-mcp) | Exam study schedules, spaced repetition dates, pomodoro plans and assignment milestones. Works offline. |
|
|
218
|
+
| [Paper Finder MCP](https://github.com/AnonymousOnyx22/paper-finder-student-mcp) | Search scholarly papers, find open access copies and related work through OpenAlex. No key needed. |
|
|
219
|
+
| [Open Textbook Finder MCP](https://github.com/AnonymousOnyx22/open-textbook-finder-mcp) | Find free legal copies of textbooks on OpenStax, DOAB and Open Library, look up editions by ISBN and suggest cheaper older ones. |
|
|
220
|
+
| [STEM Calculator MCP](https://github.com/AnonymousOnyx22/stem-calc-student-mcp) | Chemistry and physics homework math: molar mass, balancing equations, grams to moles, unit conversion and significant figures. Works offline. |
|
|
221
|
+
| [Essay Stats MCP](https://github.com/AnonymousOnyx22/essay-stats-mcp) | Offline writing metrics for essays. Word and page counts, readability scores, passive voice, repeated openers, filler words, long sentences and word limit checks, so students can check their work against assignment requirements. No account, no network. |
|
|
222
|
+
| [Syllabus to Calendar MCP](https://github.com/AnonymousOnyx22/syllabus-calendar-mcp) | Syllabus to calendar MCP server. Extract dates from syllabus text, build an ics calendar, expand recurring classes and merge calendars. Works offline, no account. |
|
|
223
|
+
| [Job Application Tracker MCP](https://github.com/AnonymousOnyx22/job-tracker-student-mcp) | Job application tracker MCP server. Track internships, jobs, interviews and deadlines offline, see what is coming up, and export your list as CSV or Markdown. No account and no API keys. |
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ascii, cleanDoi, plain } from "./bib.js";
|
|
2
|
+
/* ---------- duplicates ---------- */
|
|
3
|
+
export function normalizeTitle(t) {
|
|
4
|
+
return ascii(plain(t))
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
7
|
+
.trim();
|
|
8
|
+
}
|
|
9
|
+
function bigrams(s) {
|
|
10
|
+
const m = new Map();
|
|
11
|
+
const t = s.replace(/ /g, "");
|
|
12
|
+
for (let i = 0; i < t.length - 1; i++) {
|
|
13
|
+
const g = t.slice(i, i + 2);
|
|
14
|
+
m.set(g, (m.get(g) ?? 0) + 1);
|
|
15
|
+
}
|
|
16
|
+
return m;
|
|
17
|
+
}
|
|
18
|
+
/** Dice coefficient on character pairs, from 0 to 1. */
|
|
19
|
+
export function titleSimilarity(a, b) {
|
|
20
|
+
const x = normalizeTitle(a);
|
|
21
|
+
const y = normalizeTitle(b);
|
|
22
|
+
if (!x || !y)
|
|
23
|
+
return 0;
|
|
24
|
+
if (x === y)
|
|
25
|
+
return 1;
|
|
26
|
+
const bx = bigrams(x);
|
|
27
|
+
const by = bigrams(y);
|
|
28
|
+
let overlap = 0;
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const v of bx.values())
|
|
31
|
+
total += v;
|
|
32
|
+
for (const v of by.values())
|
|
33
|
+
total += v;
|
|
34
|
+
for (const [g, c] of bx)
|
|
35
|
+
overlap += Math.min(c, by.get(g) ?? 0);
|
|
36
|
+
return total ? (2 * overlap) / total : 0;
|
|
37
|
+
}
|
|
38
|
+
export function findDuplicates(entries, threshold) {
|
|
39
|
+
const n = entries.length;
|
|
40
|
+
const parent = entries.map((_, i) => i);
|
|
41
|
+
const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
|
|
42
|
+
const reasons = new Map();
|
|
43
|
+
const link = (a, b, why) => {
|
|
44
|
+
const ra = find(a);
|
|
45
|
+
const rb = find(b);
|
|
46
|
+
if (ra !== rb)
|
|
47
|
+
parent[rb] = ra;
|
|
48
|
+
// Record the reason against both members, the root is settled afterwards.
|
|
49
|
+
for (const i of [a, b]) {
|
|
50
|
+
if (!reasons.has(i))
|
|
51
|
+
reasons.set(i, new Set());
|
|
52
|
+
reasons.get(i).add(why);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
for (let i = 0; i < n; i++) {
|
|
56
|
+
for (let j = i + 1; j < n; j++) {
|
|
57
|
+
const a = entries[i];
|
|
58
|
+
const b = entries[j];
|
|
59
|
+
const da = a.fields.doi ? cleanDoi(a.fields.doi).toLowerCase() : "";
|
|
60
|
+
const db = b.fields.doi ? cleanDoi(b.fields.doi).toLowerCase() : "";
|
|
61
|
+
if (da && da === db)
|
|
62
|
+
link(i, j, "doi");
|
|
63
|
+
if (a.key && a.key.toLowerCase() === b.key.toLowerCase())
|
|
64
|
+
link(i, j, "key");
|
|
65
|
+
const ya = a.fields.year;
|
|
66
|
+
const yb = b.fields.year;
|
|
67
|
+
const yearsClash = ya && yb && ya !== yb;
|
|
68
|
+
if (!yearsClash && a.fields.title && b.fields.title && titleSimilarity(a.fields.title, b.fields.title) >= threshold)
|
|
69
|
+
link(i, j, "title");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const groups = new Map();
|
|
73
|
+
for (let i = 0; i < n; i++) {
|
|
74
|
+
const r = find(i);
|
|
75
|
+
groups.set(r, [...(groups.get(r) ?? []), i]);
|
|
76
|
+
}
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const members of groups.values()) {
|
|
79
|
+
if (members.length < 2)
|
|
80
|
+
continue;
|
|
81
|
+
const why = new Set();
|
|
82
|
+
for (const i of members)
|
|
83
|
+
for (const r of reasons.get(i) ?? [])
|
|
84
|
+
why.add(r);
|
|
85
|
+
// Keep the entry with the most fields, the earliest one on a tie.
|
|
86
|
+
const best = members.reduce((p, c) => (Object.keys(entries[c].fields).length > Object.keys(entries[p].fields).length ? c : p), members[0]);
|
|
87
|
+
out.push({
|
|
88
|
+
indexes: members,
|
|
89
|
+
keys: members.map((i) => entries[i].key),
|
|
90
|
+
reasons: ["doi", "key", "title"].filter((r) => why.has(r)),
|
|
91
|
+
keepIndex: best,
|
|
92
|
+
keep: entries[best].key,
|
|
93
|
+
remove: members.filter((i) => i !== best).map((i) => entries[i].key),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out.sort((a, b) => a.indexes[0] - b.indexes[0]);
|
|
97
|
+
}
|
|
98
|
+
const REQUIRED = {
|
|
99
|
+
article: [["author"], ["title"], ["journal"], ["year"]],
|
|
100
|
+
book: [["author", "editor"], ["title"], ["publisher"], ["year"]],
|
|
101
|
+
inproceedings: [["author"], ["title"], ["booktitle"], ["year"]],
|
|
102
|
+
incollection: [["author"], ["title"], ["booktitle"], ["publisher"], ["year"]],
|
|
103
|
+
phdthesis: [["author"], ["title"], ["school"], ["year"]],
|
|
104
|
+
mastersthesis: [["author"], ["title"], ["school"], ["year"]],
|
|
105
|
+
techreport: [["author"], ["title"], ["institution"], ["year"]],
|
|
106
|
+
unpublished: [["author"], ["title"], ["note"]],
|
|
107
|
+
misc: [["title"]],
|
|
108
|
+
online: [["title"], ["url"]],
|
|
109
|
+
};
|
|
110
|
+
const SMALL_WORDS = new Set(["a", "an", "the", "and", "or", "of", "in", "on", "for", "to", "with", "at", "by", "from", "as", "but", "nor", "vs"]);
|
|
111
|
+
export function pageProblem(pages) {
|
|
112
|
+
const p = pages.trim();
|
|
113
|
+
if (/^[A-Za-z]?\d+$/.test(p))
|
|
114
|
+
return null;
|
|
115
|
+
if (/^\d+--\d+$/.test(p)) {
|
|
116
|
+
const [a, b] = p.split("--").map(Number);
|
|
117
|
+
return b < a ? `the range ${p} ends before it starts` : null;
|
|
118
|
+
}
|
|
119
|
+
if (/^\d+\s*[-–—]\s*\d+$/.test(p)) {
|
|
120
|
+
const [a, b] = p.split(/[-–—]/).map(Number);
|
|
121
|
+
return b < a ? `the range ${p} ends before it starts` : "use a double hyphen between the pages, like 12--15";
|
|
122
|
+
}
|
|
123
|
+
if (/^\d+\s*[-–—]+\s*\d+\s*[-–—]+\s*\d+$/.test(p))
|
|
124
|
+
return "the range has more than two page numbers";
|
|
125
|
+
return `"${p}" is not a page number or a range like 12--15`;
|
|
126
|
+
}
|
|
127
|
+
export function lintEntries(entries) {
|
|
128
|
+
const issues = [];
|
|
129
|
+
const seen = new Map();
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
const k = e.key.toLowerCase();
|
|
132
|
+
seen.set(k, (seen.get(k) ?? 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
const reportedKeys = new Set();
|
|
135
|
+
for (const e of entries) {
|
|
136
|
+
const f = e.fields;
|
|
137
|
+
const add = (rule, severity, message, field) => issues.push({ key: e.key, rule, severity, message, ...(field ? { field } : {}) });
|
|
138
|
+
if ((seen.get(e.key.toLowerCase()) ?? 0) > 1 && !reportedKeys.has(e.key.toLowerCase())) {
|
|
139
|
+
reportedKeys.add(e.key.toLowerCase());
|
|
140
|
+
add("duplicate-key", "error", `The key ${e.key} is used ${seen.get(e.key.toLowerCase())} times.`);
|
|
141
|
+
}
|
|
142
|
+
for (const group of REQUIRED[e.type] ?? [["title"]]) {
|
|
143
|
+
if (!group.some((g) => f[g])) {
|
|
144
|
+
add("missing-field", e.type === "misc" || e.type === "online" ? "warning" : "error", `A ${e.type} needs ${group.join(" or ")}, and it is missing.`, group[0]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (f.pages) {
|
|
148
|
+
const problem = pageProblem(f.pages);
|
|
149
|
+
if (problem)
|
|
150
|
+
add("bad-pages", "warning", `Pages: ${problem}.`, "pages");
|
|
151
|
+
}
|
|
152
|
+
if (f.title) {
|
|
153
|
+
const words = plain(f.title).split(" ").filter(Boolean);
|
|
154
|
+
const raw = f.title.trim();
|
|
155
|
+
if (/^[a-z]/.test(raw)) {
|
|
156
|
+
add("uncapitalized-title", "warning", `The title starts with a lower case letter: "${plain(f.title)}".`, "title");
|
|
157
|
+
}
|
|
158
|
+
else if (words.length > 3 && words.filter((w) => !SMALL_WORDS.has(w.toLowerCase())).every((w) => w === w.toLowerCase())) {
|
|
159
|
+
add("uncapitalized-title", "warning", `Every word of the title is lower case: "${plain(f.title)}".`, "title");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (f.doi && !/^10\.\d{4,9}\/\S+$/.test(cleanDoi(f.doi))) {
|
|
163
|
+
add("invalid-doi", "error", `"${f.doi}" is not a valid DOI. A DOI looks like 10.1000/xyz123.`, "doi");
|
|
164
|
+
}
|
|
165
|
+
if (f.year && !/^\d{4}$/.test(f.year))
|
|
166
|
+
add("bad-year", "warning", `The year "${f.year}" is not a four digit year.`, "year");
|
|
167
|
+
}
|
|
168
|
+
return issues;
|
|
169
|
+
}
|