format_bibtex.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. #!/usr/bin/env python3
  2. """
  3. BibTeX Formatter and Cleaner
  4. Format, clean, sort, and deduplicate BibTeX files.
  5. """
  6. import sys
  7. import re
  8. import argparse
  9. from typing import List, Dict, Tuple
  10. from collections import OrderedDict
  11. class BibTeXFormatter:
  12. """Format and clean BibTeX entries."""
  13. def __init__(self):
  14. # Standard field order for readability
  15. self.field_order = [
  16. 'author', 'editor', 'title', 'booktitle', 'journal',
  17. 'year', 'month', 'volume', 'number', 'pages',
  18. 'publisher', 'address', 'edition', 'series',
  19. 'school', 'institution', 'organization',
  20. 'howpublished', 'doi', 'url', 'isbn', 'issn',
  21. 'note', 'abstract', 'keywords'
  22. ]
  23. def parse_bibtex_file(self, filepath: str) -> List[Dict]:
  24. """
  25. Parse BibTeX file and extract entries.
  26. Args:
  27. filepath: Path to BibTeX file
  28. Returns:
  29. List of entry dictionaries
  30. """
  31. try:
  32. with open(filepath, 'r', encoding='utf-8') as f:
  33. content = f.read()
  34. except Exception as e:
  35. print(f'Error reading file: {e}', file=sys.stderr)
  36. return []
  37. entries = []
  38. # Match BibTeX entries
  39. pattern = r'@(\w+)\s*\{\s*([^,\s]+)\s*,(.*?)\n\}'
  40. matches = re.finditer(pattern, content, re.DOTALL | re.IGNORECASE)
  41. for match in matches:
  42. entry_type = match.group(1).lower()
  43. citation_key = match.group(2).strip()
  44. fields_text = match.group(3)
  45. # Parse fields
  46. fields = OrderedDict()
  47. field_pattern = r'(\w+)\s*=\s*\{([^}]*)\}|(\w+)\s*=\s*"([^"]*)"'
  48. field_matches = re.finditer(field_pattern, fields_text)
  49. for field_match in field_matches:
  50. if field_match.group(1):
  51. field_name = field_match.group(1).lower()
  52. field_value = field_match.group(2)
  53. else:
  54. field_name = field_match.group(3).lower()
  55. field_value = field_match.group(4)
  56. fields[field_name] = field_value.strip()
  57. entries.append({
  58. 'type': entry_type,
  59. 'key': citation_key,
  60. 'fields': fields
  61. })
  62. return entries
  63. def format_entry(self, entry: Dict) -> str:
  64. """
  65. Format a single BibTeX entry.
  66. Args:
  67. entry: Entry dictionary
  68. Returns:
  69. Formatted BibTeX string
  70. """
  71. lines = [f'@{entry["type"]}{{{entry["key"]},']
  72. # Order fields according to standard order
  73. ordered_fields = OrderedDict()
  74. # Add fields in standard order
  75. for field_name in self.field_order:
  76. if field_name in entry['fields']:
  77. ordered_fields[field_name] = entry['fields'][field_name]
  78. # Add any remaining fields
  79. for field_name, field_value in entry['fields'].items():
  80. if field_name not in ordered_fields:
  81. ordered_fields[field_name] = field_value
  82. # Format each field
  83. max_field_len = max(len(f) for f in ordered_fields.keys()) if ordered_fields else 0
  84. for field_name, field_value in ordered_fields.items():
  85. # Pad field name for alignment
  86. padded_field = field_name.ljust(max_field_len)
  87. lines.append(f' {padded_field} = {{{field_value}}},')
  88. # Remove trailing comma from last field
  89. if lines[-1].endswith(','):
  90. lines[-1] = lines[-1][:-1]
  91. lines.append('}')
  92. return '\n'.join(lines)
  93. def fix_common_issues(self, entry: Dict) -> Dict:
  94. """
  95. Fix common formatting issues in entry.
  96. Args:
  97. entry: Entry dictionary
  98. Returns:
  99. Fixed entry dictionary
  100. """
  101. fixed = entry.copy()
  102. fields = fixed['fields'].copy()
  103. # Fix page ranges (single hyphen to double hyphen)
  104. if 'pages' in fields:
  105. pages = fields['pages']
  106. # Replace single hyphen with double hyphen if it's a range
  107. if re.search(r'\d-\d', pages) and '--' not in pages:
  108. pages = re.sub(r'(\d)-(\d)', r'\1--\2', pages)
  109. fields['pages'] = pages
  110. # Remove "pp." from pages
  111. if 'pages' in fields:
  112. pages = fields['pages']
  113. pages = re.sub(r'^pp\.\s*', '', pages, flags=re.IGNORECASE)
  114. fields['pages'] = pages
  115. # Fix DOI (remove URL prefix if present)
  116. if 'doi' in fields:
  117. doi = fields['doi']
  118. doi = doi.replace('https://doi.org/', '')
  119. doi = doi.replace('http://doi.org/', '')
  120. doi = doi.replace('doi:', '')
  121. fields['doi'] = doi
  122. # Fix author separators (semicolon or ampersand to 'and')
  123. if 'author' in fields:
  124. author = fields['author']
  125. author = author.replace(';', ' and')
  126. author = author.replace(' & ', ' and ')
  127. # Clean up multiple 'and's
  128. author = re.sub(r'\s+and\s+and\s+', ' and ', author)
  129. fields['author'] = author
  130. fixed['fields'] = fields
  131. return fixed
  132. def deduplicate_entries(self, entries: List[Dict]) -> List[Dict]:
  133. """
  134. Remove duplicate entries based on DOI or citation key.
  135. Args:
  136. entries: List of entry dictionaries
  137. Returns:
  138. List of unique entries
  139. """
  140. seen_dois = set()
  141. seen_keys = set()
  142. unique_entries = []
  143. for entry in entries:
  144. doi = entry['fields'].get('doi', '').strip()
  145. key = entry['key']
  146. # Check DOI first (more reliable)
  147. if doi:
  148. if doi in seen_dois:
  149. print(f'Duplicate DOI found: {doi} (skipping {key})', file=sys.stderr)
  150. continue
  151. seen_dois.add(doi)
  152. # Check citation key
  153. if key in seen_keys:
  154. print(f'Duplicate citation key found: {key} (skipping)', file=sys.stderr)
  155. continue
  156. seen_keys.add(key)
  157. unique_entries.append(entry)
  158. return unique_entries
  159. def sort_entries(self, entries: List[Dict], sort_by: str = 'key', descending: bool = False) -> List[Dict]:
  160. """
  161. Sort entries by specified field.
  162. Args:
  163. entries: List of entry dictionaries
  164. sort_by: Field to sort by ('key', 'year', 'author', 'title')
  165. descending: Sort in descending order
  166. Returns:
  167. Sorted list of entries
  168. """
  169. def get_sort_key(entry: Dict) -> str:
  170. if sort_by == 'key':
  171. return entry['key'].lower()
  172. elif sort_by == 'year':
  173. year = entry['fields'].get('year', '9999')
  174. return year
  175. elif sort_by == 'author':
  176. author = entry['fields'].get('author', 'ZZZ')
  177. # Get last name of first author
  178. if ',' in author:
  179. return author.split(',')[0].lower()
  180. else:
  181. return author.split()[0].lower() if author else 'zzz'
  182. elif sort_by == 'title':
  183. return entry['fields'].get('title', '').lower()
  184. else:
  185. return entry['key'].lower()
  186. return sorted(entries, key=get_sort_key, reverse=descending)
  187. def format_file(self, filepath: str, output: str = None,
  188. deduplicate: bool = False, sort_by: str = None,
  189. descending: bool = False, fix_issues: bool = True) -> None:
  190. """
  191. Format entire BibTeX file.
  192. Args:
  193. filepath: Input BibTeX file
  194. output: Output file (None for in-place)
  195. deduplicate: Remove duplicates
  196. sort_by: Field to sort by
  197. descending: Sort in descending order
  198. fix_issues: Fix common formatting issues
  199. """
  200. print(f'Parsing {filepath}...', file=sys.stderr)
  201. entries = self.parse_bibtex_file(filepath)
  202. if not entries:
  203. print('No entries found', file=sys.stderr)
  204. return
  205. print(f'Found {len(entries)} entries', file=sys.stderr)
  206. # Fix common issues
  207. if fix_issues:
  208. print('Fixing common issues...', file=sys.stderr)
  209. entries = [self.fix_common_issues(e) for e in entries]
  210. # Deduplicate
  211. if deduplicate:
  212. print('Removing duplicates...', file=sys.stderr)
  213. original_count = len(entries)
  214. entries = self.deduplicate_entries(entries)
  215. removed = original_count - len(entries)
  216. if removed > 0:
  217. print(f'Removed {removed} duplicate(s)', file=sys.stderr)
  218. # Sort
  219. if sort_by:
  220. print(f'Sorting by {sort_by}...', file=sys.stderr)
  221. entries = self.sort_entries(entries, sort_by, descending)
  222. # Format entries
  223. print('Formatting entries...', file=sys.stderr)
  224. formatted_entries = [self.format_entry(e) for e in entries]
  225. # Write output
  226. output_content = '\n\n'.join(formatted_entries) + '\n'
  227. output_file = output or filepath
  228. try:
  229. with open(output_file, 'w', encoding='utf-8') as f:
  230. f.write(output_content)
  231. print(f'Successfully wrote {len(entries)} entries to {output_file}', file=sys.stderr)
  232. except Exception as e:
  233. print(f'Error writing file: {e}', file=sys.stderr)
  234. sys.exit(1)
  235. def main():
  236. """Command-line interface."""
  237. parser = argparse.ArgumentParser(
  238. description='Format, clean, sort, and deduplicate BibTeX files',
  239. epilog='Example: python format_bibtex.py references.bib --deduplicate --sort year'
  240. )
  241. parser.add_argument(
  242. 'file',
  243. help='BibTeX file to format'
  244. )
  245. parser.add_argument(
  246. '-o', '--output',
  247. help='Output file (default: overwrite input file)'
  248. )
  249. parser.add_argument(
  250. '--deduplicate',
  251. action='store_true',
  252. help='Remove duplicate entries'
  253. )
  254. parser.add_argument(
  255. '--sort',
  256. choices=['key', 'year', 'author', 'title'],
  257. help='Sort entries by field'
  258. )
  259. parser.add_argument(
  260. '--descending',
  261. action='store_true',
  262. help='Sort in descending order'
  263. )
  264. parser.add_argument(
  265. '--no-fix',
  266. action='store_true',
  267. help='Do not fix common issues'
  268. )
  269. args = parser.parse_args()
  270. # Format file
  271. formatter = BibTeXFormatter()
  272. formatter.format_file(
  273. args.file,
  274. output=args.output,
  275. deduplicate=args.deduplicate,
  276. sort_by=args.sort,
  277. descending=args.descending,
  278. fix_issues=not args.no_fix
  279. )
  280. if __name__ == '__main__':
  281. main()