diff --git a/src/article_cli/zotero.py b/src/article_cli/zotero.py index 128db24..1897c7d 100644 --- a/src/article_cli/zotero.py +++ b/src/article_cli/zotero.py @@ -7,7 +7,7 @@ import re from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +from typing import Any, Dict, Iterator, List, Optional, Sequence, Set, Tuple import time from .reporting import print_error, print_info, print_success, print_warning @@ -28,41 +28,63 @@ TYPST_CITE_RE = re.compile(r"(? List[str]: - """Split BibTeX content into balanced entries.""" - entries: List[str] = [] - index = 0 - while True: - start = content.find("@", index) - if start == -1: - break - - brace = content.find("{", start) - if brace == -1: - break - - depth = 0 - end = brace - while end < len(content): - char = content[end] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - end += 1 - break - end += 1 +BIBTEX_START_RE = re.compile(r"@(\w+)\s*\{") + + +def _bibtex_entry_spans(content: str) -> Iterator[Tuple[int, int, str, List[int]]]: + """Yield complete entries and their top-level comma positions. - if depth == 0: - entry = content[start:end].strip() - if entry: - entries.append(entry) - index = end + Nested values, quoted strings, escaped delimiters, and line comments do not + create entry or field boundaries. Incomplete input is left unclassified. + """ + index = 0 + while index < len(content): + if content[index] == "%": + newline = content.find("\n", index) + index = newline + 1 if newline >= 0 else len(content) + continue + header = BIBTEX_START_RE.match(content, index) + if header is None: + index += 1 + continue + + start = index + index = header.end() + depth = 1 + quoted = False + commas: List[int] = [] + while index < len(content): + char = content[index] + if char == "\\": + index += 2 + continue + if depth == 1 and not quoted and char == "%": + newline = content.find("\n", index) + index = newline + 1 if newline >= 0 else len(content) + continue + if char == '"' and depth == 1: + quoted = not quoted + elif not quoted: + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + index += 1 + yield start, index, header.group(1).lower(), commas + break + elif char == "," and depth == 1: + commas.append(index) + index += 1 else: - break + return - return entries + +def split_bibtex_entries(content: str) -> List[str]: + """Split BibTeX content into complete, balanced entries.""" + return [ + content[start:end].strip() for start, end, _, _ in _bibtex_entry_spans(content) + ] def extract_bibtex_keys(content: str) -> Set[str]: @@ -296,26 +318,34 @@ def _filter_empty_entries(self, content: str) -> Tuple[str, int]: Returns: Tuple of (filtered_content, num_removed) """ - import re - - # Pattern to match BibTeX entries - entry_pattern = r"@\w+\{[^,]*,\s*\}" - - # Find all empty entries (those with just the citation key and closing brace) - empty_entries = re.findall(entry_pattern, content) + pieces: List[str] = [] + cursor = 0 + removed = 0 + for start, end, entry_type, commas in _bibtex_entry_spans(content): + # BibTeX directives are not bibliography records. + if entry_type in {"comment", "string", "preamble"} or not commas: + continue + fields: List[Optional[str]] = [] + for left, right in zip(commas, commas[1:] + [end - 1]): + field = content[left + 1 : right] + # Comments before a field or after a trailing comma are harmless. + field = re.sub(r"^(?:\s|%[^\n]*(?:\n|$))*", "", field) + if not field: + continue + match = re.match(r"([A-Za-z][A-Za-z0-9_:-]*)\s*=\s*\S", field) + # Unknown/malformed fields are preserved rather than guessed empty. + fields.append(match.group(1).lower() if match else None) + if fields and fields != ["keywords"]: + continue + pieces.append(content[cursor:start]) + cursor = end + removed += 1 - if not empty_entries: + if not removed: return content, 0 - - # Remove empty entries - filtered_content = content - for entry in empty_entries: - filtered_content = filtered_content.replace(entry, "") - - # Clean up multiple blank lines - filtered_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", filtered_content) - - return filtered_content, len(empty_entries) + pieces.append(content[cursor:]) + filtered_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", "".join(pieces)) + return filtered_content, removed def _normalize_bibtex_content(self, content: str) -> str: """Return BibTeX entries sorted by citation key for stable output.""" diff --git a/tests/test_zotero_bibliography.py b/tests/test_zotero_bibliography.py index 33416c7..4bdbe6d 100644 --- a/tests/test_zotero_bibliography.py +++ b/tests/test_zotero_bibliography.py @@ -4,6 +4,8 @@ from pathlib import Path +import pytest + from article_cli.zotero import ( ZoteroBibTexUpdater, extract_bibtex_keys, @@ -164,3 +166,81 @@ def test_transient_zotero_failures_are_retried(tmp_path, monkeypatch): assert updater.update(backup=False) is True assert len(updater.session.calls) == 2 + + +@pytest.mark.parametrize( + "fields", + [ + "", + "keywords = {laser, output}", + "keywords = {laser, output},", + "KEYWORDS = {laser, {GPU}},", + 'keywords = "laser, output",', + "keywords = {{laser}, {nested {GPU}}},", + "keywords = tags_macro,", + 'keywords = {laser} # ", output" # tags_macro,', + ], +) +def test_filter_removes_only_empty_or_keywords_only_entries(tmp_path, fields): + updater = make_updater(tmp_path, []) + entry = "@misc{empty,\n" + fields + "\n}" + sentinel = "@article{keep, title = {Keep me}}" + filtered, removed = updater._filter_empty_entries(entry + "\n\n" + sentinel) + assert removed == 1 + assert filtered.strip() == sentinel + + +@pytest.mark.parametrize( + "entry", + [ + "@article{valid, keywords = {laser, {GPU}}, title = {A valid paper}}", + '@article{valid, keywords = "laser, output", title = {A valid paper}}', + "@article{valid, keywords = {laser, {nested {GPU}}}, author = {Doe, Jane}}", + "@article{valid, title = {A literal @misc{example, keywords = {GPU}}}}", + '@article{valid, title = "A quoted @misc{example, keywords = {GPU}}"}', + "@article{valid, keywords = {laser}, % preserve another field\n title = {Paper}}", + "@article{valid, keywords = {laser}, abstract = {More than keywords}}", + "@comment{An empty-looking example @misc{example, keywords = {GPU}}}", + '@string{example = "A literal @misc{example, keywords = {GPU}}"}', + ], +) +def test_filter_preserves_complete_valid_entries_and_directives(tmp_path, entry): + updater = make_updater(tmp_path, []) + filtered, removed = updater._filter_empty_entries(entry) + assert removed == 0 + assert filtered == entry + + +def test_deleting_entry_does_not_delete_identical_text_in_title_or_comment(tmp_path): + updater = make_updater(tmp_path, []) + empty = "@misc{empty, keywords = {laser}}" + valid = "@article{valid, title = {Literal " + empty + " in title}}" + comment = "% Keep this example: " + empty + filtered, removed = updater._filter_empty_entries( + comment + "\n" + valid + "\n" + empty + ) + assert removed == 1 + assert valid in filtered + assert comment in filtered + + +def test_zotero_update_preserves_nested_keywords_entry_while_removing_empty_items( + tmp_path, +): + valid = """@article{valid, + keywords = {laser, {GPU}}, + title = {A valid paper}, + author = {Doe, Jane}, + journal = {Example Journal}, + year = {2026}, +}""" + empty = """@misc{noauthor_notitle_nodate-25, keywords = {laser, output},} +@misc{noauthor_notitle_nodate-26, keywords = {laser, laserParam},} +@misc{noauthor_notitle_nodate-27, keywords = {laser, laserParam},} +""" + updater = make_updater(tmp_path, [FakeResponse(valid + "\n" + empty, total=4)]) + assert updater.update(backup=False) + content = (tmp_path / "references.bib").read_text() + assert extract_bibtex_keys(content) == {"valid"} + assert valid in content + assert "% Filtered out: 3 empty entries" in content