bibtex_tools
Parse a BibTeX file (nested braces, quoted values, @string macros joined with #, month macros, @preamble, @comment), remove duplicates by DOI and then by normalised title and year, rename keys to authorYEARword with a/b suffixes on collisions, report missing required fields per entry type from the BibTeX documentation, and write the file back as clean, consistently formatted BibTeX.
Install
/tools install bibtex_tools /tools install bibtex_tools@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · bibtex_tools 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool bibtex_tools; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
bibtex | string | yes | The .bib text. length ≤ 2000000 | |
operations | array of string | no | ["dedupe", "normalize_keys", "check"] | Applied in this order; [] only parses and rewrites |
merge | boolean | no | true | On dedupe, copy fields the kept entry lacks from its duplicates |
entries | boolean | no | true | Include the parsed entries as JSON |
Input schema (JSON)
{
"type": "object",
"required": [
"bibtex"
],
"additionalProperties": false,
"properties": {
"bibtex": {
"type": "string",
"maxLength": 2000000,
"description": "The .bib text"
},
"operations": {
"type": "array",
"items": {
"type": "string",
"enum": [
"dedupe",
"normalize_keys",
"check"
]
},
"default": [
"dedupe",
"normalize_keys",
"check"
],
"description": "Applied in this order; [] only parses and rewrites"
},
"merge": {
"type": "boolean",
"default": true,
"description": "On dedupe, copy fields the kept entry lacks from its duplicates"
},
"entries": {
"type": "boolean",
"default": true,
"description": "Include the parsed entries as JSON"
}
}
}
Example
Input
{
"bibtex": "@article{a, author = {Ada Lovelace}, title = {Notes on the engine}, year = 1843}",
"entries": false
}
Output (stdout)
{
"keys": [
{
"old": "a",
"new": "lovelace1843notes"
}
],
"missing_fields": [
{
"key": "lovelace1843notes",
"type": "article",
"missing": [
"journal"
]
}
],
"bibtex": "@article{lovelace1843notes,\n author = {Ada Lovelace},\n title = {Notes on the engine},\n year = {1843},\n}\n"
}
Files
The command the install card registers is python3 bibtex_tools.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
bibtex_tools.py · sha256 3948fc08bb8c0e3ed48c11ee22b38a0e59a3fe322639e9277571a1da3ec1e3cf
#!/usr/bin/env python3
"""Parse, deduplicate, re-key and check a BibTeX file, and write it back clean.
Input (JSON on stdin):
bibtex the .bib text
operations any of "dedupe", "normalize_keys", "check", applied in that order;
default all three; [] only parses and rewrites
merge on dedupe, copy fields the kept entry lacks from its duplicates (default true)
entries include the parsed entries as JSON in the output (default true)
Parsing follows BibTeX: entries @type{key, field = value, ...} or @type(key, ...), values in
braces (nested), in double quotes, bare numbers or @string macros, joined with #; the twelve
month macros jan..dec are predefined; @comment and text outside entries are ignored;
@preamble is kept. Required fields are those of btxdoc ("BibTeXing", Patashnik 1988).
"""
import json
import re
import sys
import unicodedata
MAX_TEXT = 2_000_000
MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September",
"October", "November", "December"]
MONTH_MACROS = {m[:3].lower(): m for m in MONTHS}
# btxdoc, section 3.1 ("Entry types"): required fields; a tuple means "one of these"
REQUIRED = {
"article": ["author", "title", "journal", "year"],
"book": [("author", "editor"), "title", "publisher", "year"],
"booklet": ["title"],
"conference": ["author", "title", "booktitle", "year"],
"inbook": [("author", "editor"), "title", ("chapter", "pages"), "publisher", "year"],
"incollection": ["author", "title", "booktitle", "publisher", "year"],
"inproceedings": ["author", "title", "booktitle", "year"],
"manual": ["title"],
"mastersthesis": ["author", "title", "school", "year"],
"misc": [],
"phdthesis": ["author", "title", "school", "year"],
"proceedings": ["title", "year"],
"techreport": ["author", "title", "institution", "year"],
"unpublished": ["author", "title", "note"],
}
STOPWORDS = {"a", "an", "the", "on", "of", "in", "for", "and", "to", "with", "at", "by", "from", "into", "is",
"are", "toward", "towards", "via", "as", "or", "its", "how", "what", "why", "when", "do", "does"}
SPECIAL = {"ss": "ss", "o": "o", "O": "O", "ae": "ae", "AE": "AE", "oe": "oe", "OE": "OE", "aa": "a", "AA": "A",
"l": "l", "L": "L", "i": "i", "j": "j"}
OPEN_CLOSE = {"{": "}", "(": ")"}
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
class BibError(Exception):
pass
class Scanner:
def __init__(self, text):
self.text, self.pos = text, 0
def line(self, pos=None):
return self.text.count("\n", 0, self.pos if pos is None else pos) + 1
def skip_ws(self):
while self.pos < len(self.text) and self.text[self.pos].isspace():
self.pos += 1
def peek(self):
return self.text[self.pos] if self.pos < len(self.text) else ""
def ident(self):
m = re.compile(r"[^\s\"#%'(),={}]+").match(self.text, self.pos)
if not m:
raise BibError(f"expected a name at line {self.line()}")
self.pos = m.end()
return m.group()
def braced(self):
"""At '{': return the content up to the matching '}'."""
depth, start = 0, self.pos
while self.pos < len(self.text):
ch = self.text[self.pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
self.pos += 1
return self.text[start + 1:self.pos - 1]
self.pos += 1
raise BibError(f"unbalanced braces from line {self.line(start)}")
def quoted(self):
"""At '"': the content up to the next '"' outside braces."""
depth, start = 0, self.pos
self.pos += 1
while self.pos < len(self.text):
ch = self.text[self.pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth < 0:
raise BibError(f"unbalanced braces in a quoted value at line {self.line(start)}")
elif ch == '"' and depth == 0:
self.pos += 1
return self.text[start + 1:self.pos - 1]
self.pos += 1
raise BibError(f"unterminated quoted value from line {self.line(start)}")
def value(self, strings, warnings):
parts = []
while True:
self.skip_ws()
ch = self.peek()
if ch == "{":
parts.append(self.braced())
elif ch == '"':
parts.append(self.quoted())
elif ch.isdigit():
m = re.compile(r"\d+").match(self.text, self.pos)
self.pos = m.end()
parts.append(m.group())
elif ch and ch not in ",}){(#=":
name = self.ident()
low = name.lower()
if low in strings:
parts.append(strings[low])
elif low in MONTH_MACROS:
parts.append(MONTH_MACROS[low])
else:
warnings.append(f"line {self.line()}: undefined macro '{name}' kept as text")
parts.append(name)
else:
raise BibError(f"expected a value at line {self.line()}")
self.skip_ws()
if self.peek() == "#":
self.pos += 1
continue
return "".join(parts)
def parse(text):
sc = Scanner(text)
strings, entries, preambles, warnings = {}, [], [], []
comments = 0
while True:
at = text.find("@", sc.pos)
if at < 0:
break
sc.pos = at + 1
start_line = sc.line(at)
try:
sc.skip_ws()
kind = sc.ident().lower()
sc.skip_ws()
opener = sc.peek()
if opener not in OPEN_CLOSE:
raise BibError(f"expected '{{' or '(' after @{kind} at line {start_line}")
closer = OPEN_CLOSE[opener]
if kind == "comment":
if opener == "{":
sc.braced()
else:
end = text.find(")", sc.pos)
sc.pos = len(text) if end < 0 else end + 1
comments += 1
continue
sc.pos += 1
if kind == "preamble":
preambles.append(sc.value(strings, warnings))
sc.skip_ws()
if sc.peek() != closer:
raise BibError(f"expected '{closer}' closing @preamble at line {sc.line()}")
sc.pos += 1
continue
if kind == "string":
sc.skip_ws()
name = sc.ident().lower()
sc.skip_ws()
if sc.peek() != "=":
raise BibError(f"expected '=' in @string at line {sc.line()}")
sc.pos += 1
strings[name] = sc.value(strings, warnings)
sc.skip_ws()
if sc.peek() != closer:
raise BibError(f"expected '{closer}' closing @string at line {sc.line()}")
sc.pos += 1
continue
sc.skip_ws()
m = re.compile(r"[^,\s{}()]*").match(text, sc.pos)
key = m.group()
sc.pos = m.end()
sc.skip_ws()
fields = {}
if sc.peek() == ",":
sc.pos += 1
elif sc.peek() != closer:
raise BibError(f"expected ',' after the key of @{kind} at line {start_line}")
while True:
sc.skip_ws()
if sc.peek() == closer:
sc.pos += 1
break
if sc.peek() == "":
raise BibError(f"@{kind}{{{key} at line {start_line} is not closed")
name = sc.ident().lower()
sc.skip_ws()
if sc.peek() != "=":
raise BibError(f"expected '=' after field '{name}' at line {sc.line()}")
sc.pos += 1
value = sc.value(strings, warnings)
if name in fields:
warnings.append(f"line {start_line}: {key}: repeated field '{name}', the first is kept")
else:
fields[name] = value
sc.skip_ws()
if sc.peek() == ",":
sc.pos += 1
elif sc.peek() != closer:
raise BibError(f"expected ',' or '{closer}' after field '{name}' at line {sc.line()}")
if not key:
warnings.append(f"line {start_line}: @{kind} without a key")
entries.append({"type": kind, "key": key, "fields": fields, "line": start_line})
except BibError as error:
warnings.append(f"skipped an entry: {error}")
# resume after the failed '@'
sc.pos = at + 1
return entries, strings, preambles, comments, warnings
def plain(value):
"""LaTeX-free ASCII: accents and special letters folded, commands and braces dropped."""
s = re.sub(r"\\[\'\"`^~=.]\s*\{?\s*\\?([A-Za-z])\}?", r"\1", value)
s = re.sub(r"\\[uvHckbdrt](?:\s*\{\s*\\?([A-Za-z])\s*\}|\s+([A-Za-z]))", lambda m: m.group(1) or m.group(2), s)
s = re.sub(r"\\([A-Za-z]+)\b\s*", lambda m: SPECIAL.get(m.group(1), " "), s)
s = s.replace("{", "").replace("}", "").replace("~", " ")
s = unicodedata.normalize("NFKD", s)
return "".join(ch for ch in s if not unicodedata.combining(ch)).encode("ascii", "ignore").decode()
def split_top(text, separator):
"""Split on a regex separator at brace depth 0."""
out, depth, start, i = [], 0, 0, 0
pattern = re.compile(separator, re.I)
while i < len(text):
ch = text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
elif depth == 0:
m = pattern.match(text, i)
if m and m.end() > i:
out.append(text[start:i])
start = i = m.end()
continue
i += 1
out.append(text[start:])
return [part.strip() for part in out if part.strip()]
def last_name(name):
"""The last name of a BibTeX name, without the von part ('van Beethoven, L.' -> 'Beethoven')."""
parts = split_top(name, r",")
if len(parts) > 1:
words = split_top(parts[0], r"\s+")
else:
words = split_top(name, r"\s+")
if len(words) > 1:
von = [i for i, w in enumerate(words[:-1]) if w[:1].islower()]
words = words[von[-1] + 1:] if von else words[-1:]
kept = [w for w in words if not w[:1].islower()] or words
return re.sub(r"[^a-z0-9]", "", plain(" ".join(kept)).lower())
def base_key(entry):
fields = entry["fields"]
people = fields.get("author") or fields.get("editor") or ""
names = split_top(people, r"\s+and\s+")
who = last_name(names[0]) if names else ""
year = re.search(r"\d{4}", fields.get("year", ""))
words = [w for w in re.split(r"[^a-z0-9]+", plain(fields.get("title", "")).lower()) if w]
first = next((w for w in words if w not in STOPWORDS), words[0] if words else "")
return (who or "anon") + (year.group() if year else "nd") + first
def suffix(n):
letters = ""
n += 1
while n:
n, r = divmod(n - 1, 26)
letters = chr(97 + r) + letters
return letters
def norm_doi(value):
v = value.strip().lower()
v = re.sub(r"^(https?://)?(dx\.)?doi\.org/", "", v)
v = re.sub(r"^doi:\s*", "", v)
return v.rstrip(".,; ")
def norm_title(value):
return re.sub(r"[^a-z0-9]", "", plain(value).lower())
def dedupe(entries, merge):
kept, by_doi, by_title, removed = [], {}, {}, []
for entry in entries:
f = entry["fields"]
doi = norm_doi(f["doi"]) if f.get("doi", "").strip() else None
title = norm_title(f.get("title", ""))
year = re.search(r"\d{4}", f.get("year", ""))
tkey = (title, year.group() if year else "") if title else None
match, reason = None, None
if doi and doi in by_doi:
match, reason = by_doi[doi], f"same DOI {doi}"
elif tkey and tkey in by_title:
other = by_title[tkey]
other_doi = norm_doi(other["fields"]["doi"]) if other["fields"].get("doi", "").strip() else None
if not (doi and other_doi and doi != other_doi):
match, reason = other, "same normalised title and year"
if match is None:
kept.append(entry)
if doi:
by_doi[doi] = entry
if tkey:
by_title.setdefault(tkey, entry)
continue
merged = []
if merge:
for name, value in f.items():
if not match["fields"].get(name, "").strip() and value.strip():
match["fields"][name] = value
merged.append(name)
if doi and doi not in by_doi:
by_doi[doi] = match
removed.append({"kept": match["key"], "removed": entry["key"], "line": entry["line"], "reason": reason,
"fields_merged": merged})
return kept, removed
def normalize_keys(entries):
bases = [base_key(e) for e in entries]
groups = {}
for i, b in enumerate(bases):
groups.setdefault(b, []).append(i)
taken = set(b for b, members in groups.items() if len(members) == 1)
changes = []
for b, members in groups.items():
if len(members) == 1:
continue
n = 0
for i in members:
while b + suffix(n) in taken:
n += 1
bases[i] = b + suffix(n)
taken.add(bases[i])
n += 1
for entry, new in zip(entries, bases):
changes.append({"old": entry["key"], "new": new})
entry["original_key"] = entry["key"]
entry["key"] = new
return changes
def check(entries):
report = []
for entry in entries:
rules = REQUIRED.get(entry["type"])
f = entry["fields"]
present = lambda name: bool(f.get(name, "").strip())
if rules is None:
report.append({"key": entry["key"], "type": entry["type"], "missing": [],
"note": "not one of the 14 standard BibTeX entry types; nothing checked"})
continue
missing = []
for rule in rules:
if isinstance(rule, tuple):
if not any(present(r) for r in rule):
missing.append(" or ".join(rule))
elif not present(rule):
missing.append(rule)
if missing:
report.append({"key": entry["key"], "type": entry["type"], "missing": missing})
return report
def month_macro(value):
v = value.strip().lower().rstrip(".")
for i, m in enumerate(MONTHS):
if v in (m.lower(), m[:3].lower(), str(i + 1), f"{i + 1:02d}"):
return m[:3].lower()
return None
def write(entries, preambles):
blocks = [f"@preamble{{{{{p}}}}}" for p in preambles]
for entry in entries:
lines = [f"@{entry['type']}{{{entry['key']},"]
for name, value in entry["fields"].items():
macro = month_macro(value) if name == "month" else None
lines.append(f" {name} = {macro}," if macro else f" {name} = {{{value}}},")
lines.append("}")
blocks.append("\n".join(lines))
return "\n\n".join(blocks) + ("\n" if blocks else "")
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = set(request) - {"bibtex", "operations", "merge", "entries"}
if extra:
fail(f"unknown field(s): {', '.join(sorted(extra))}")
text = request.get("bibtex")
if not isinstance(text, str):
fail("bibtex must be a string with the .bib text")
if len(text) > MAX_TEXT:
fail(f"bibtex may be at most {MAX_TEXT} characters")
operations = request.get("operations", ["dedupe", "normalize_keys", "check"])
if not isinstance(operations, list) or any(op not in ("dedupe", "normalize_keys", "check") for op in operations):
fail("operations must be a list of dedupe, normalize_keys and check")
for flag in ("merge", "entries"):
if not isinstance(request.get(flag, True), bool):
fail(f"{flag} must be true or false")
entries, strings, preambles, comments, warnings = parse(text)
out = {"counts": {"entries_in": len(entries), "strings": len(strings), "preambles": len(preambles),
"comments": comments}}
if "dedupe" in operations:
entries, removed = dedupe(entries, request.get("merge", True))
out["duplicates"] = removed
if "normalize_keys" in operations:
out["keys"] = normalize_keys(entries)
else:
seen = {}
for e in entries:
seen[e["key"]] = seen.get(e["key"], 0) + 1
clashes = sorted(k for k, n in seen.items() if n > 1)
if clashes:
warnings.append(f"repeated keys: {', '.join(clashes)}")
if "check" in operations:
out["missing_fields"] = check(entries)
out["missing_fields_rule"] = "required fields of the standard entry types, btxdoc (Patashnik, BibTeXing, 1988)"
out["counts"]["entries_out"] = len(entries)
if request.get("entries", True):
out["entries"] = [{k: e[k] for k in ("type", "key", "original_key", "line", "fields") if k in e} for e in entries]
out["warnings"] = warnings
out["bibtex"] = write(entries, preambles)
print(json.dumps(out, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # never a traceback
fail(f"{type(error).__name__}: {error}")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/bibtex_tools/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/bibtex_tools/1.0.0/bibtex_tools.py