Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 81 additions & 51 deletions src/article_cli/zotero.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,41 +28,63 @@
TYPST_CITE_RE = re.compile(r"(?<![\w.-])@([A-Za-z0-9_:+.-]+)")


def split_bibtex_entries(content: str) -> 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]:
Expand Down Expand Up @@ -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."""
Expand Down
80 changes: 80 additions & 0 deletions tests/test_zotero_bibliography.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from pathlib import Path

import pytest

from article_cli.zotero import (
ZoteroBibTexUpdater,
extract_bibtex_keys,
Expand Down Expand Up @@ -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
Loading