fix: bib.sync and XML parsers — Zotero key validation, creators, journalArticle, itertext

bib.sync:
- Keys validated against Zotero allowedKeyChars (no 0, 1, O, lowercase)
- Creators parsed from extra into Zotero creators/itemCreators tables
- PubMed articles pushed as journalArticle (typeID=22) not document
- DOI, PMID, Journal parsed into dedicated Zotero fields (8, 86, 41)
- Authors stripped from extra to avoid duplication

XML parsers (search_pubmed, snowball_citations):
- _text() uses itertext() instead of .text to handle mixed-content XML
  (sup, i, sub tags inside ArticleTitle) — fixes 20 blank titles

Verified: 10,262 items, 0 blank titles, 0 invalid keys, 60,866 creators,
9,658 DOIs, 10,232 PMIDs in proper Zotero fields.
This commit is contained in:
kert
2026-03-25 14:04:32 -04:00
parent aaa968bab0
commit d7c624b127
3 changed files with 165 additions and 17 deletions

View File

@@ -198,11 +198,18 @@ def efetch_articles(
def _text(el: ET.Element | None, path: str, default: str = "") -> str:
"""Get text from an XML element, handling None."""
"""Get all text from an XML element, including mixed content children.
PubMed XML uses inline markup like ``<sup>``, ``<i>``, ``<sub>``
inside ``<ArticleTitle>`` — ``.text`` only returns text before the
first child element. ``itertext()`` concatenates all text nodes.
"""
if el is None:
return default
node = el.find(path)
return (node.text or default) if node is not None else default
if node is None:
return default
return "".join(node.itertext()).strip() or default
def _parse_pubmed_xml(xml_text: str) -> list[Article]:

View File

@@ -96,10 +96,13 @@ def elink_cited_by(pmids: list[str], batch_size: int = 50) -> dict[str, list[str
def _text(el: ET.Element | None, path: str, default: str = "") -> str:
"""Get all text including mixed-content children (sup, i, sub, etc.)."""
if el is None:
return default
node = el.find(path)
return (node.text or default) if node is not None else default
if node is None:
return default
return "".join(node.itertext()).strip() or default
def efetch_basic(pmids: list[str], batch_size: int = 100) -> list[dict]:
@@ -152,7 +155,7 @@ def efetch_basic(pmids: list[str], batch_size: int = 100) -> list[dict]:
# Journal + year
journal_el = article_el.find("Journal")
journal = _text(journal_el, "Title") if journal_el else ""
journal = _text(journal_el, "Title") if journal_el is not None else ""
year = ""
pub_date = article_el.find(".//PubDate")
if pub_date is not None:

View File

@@ -34,6 +34,7 @@ _TYPE_MAP = {
"manual": 34, # report
"download": 40, # webpage
"source": 14, # document
"journal-article": 22, # journalArticle
}
# fieldName → fieldID (from Zotero's fields table)
@@ -62,13 +63,34 @@ _FIELD_IDS = {
"place": 26,
"websiteTitle": 123,
"websiteType": 42,
# journalArticle fields
"DOI": 8,
"PMID": 86,
"PMCID": 87,
"ISSN": 44,
"publicationTitle": 41,
"journalAbbreviation": 85,
"volume": 22,
"issue": 67,
}
_ALLOWED_KEY_CHARS = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
def _zotero_key() -> str:
"""Generate an 8-char Zotero key using the allowed character set."""
chars = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
return "".join(random.choices(chars, k=8)) # noqa: S311
"""Generate an 8-char Zotero key using the allowed character set.
Zotero keys must be exactly 8 chars from ``23456789ABCDEFGHIJKLMNPQRSTUVWXYZ``.
No ``0``, ``1``, ``O``, or lowercase.
Source: ``Zotero.Utilities.allowedKeyChars`` in utilities.js.
"""
return "".join(random.choices(_ALLOWED_KEY_CHARS, k=8)) # noqa: S311
def _is_valid_zotero_key(key: str) -> bool:
"""Check if a key is a valid Zotero object key."""
return len(key) == 8 and all(c in _ALLOWED_KEY_CHARS for c in key)
def _now_iso() -> str:
@@ -115,6 +137,97 @@ def _set_field(
)
def _ensure_creator(con: sqlite3.Connection, first_name: str, last_name: str) -> int:
"""Find or create a creator row. Returns creatorID."""
row = con.execute(
"SELECT creatorID FROM creators WHERE firstName = ? AND lastName = ?",
(first_name, last_name),
).fetchone()
if row:
return row[0]
cur = con.execute(
"INSERT INTO creators (firstName, lastName, fieldMode) VALUES (?, ?, 0)",
(first_name, last_name),
)
return cur.lastrowid
def _parse_authors_from_extra(extra: str) -> tuple[list[tuple[str, str]], str]:
"""Parse 'Authors: Last First; Last First' from extra, return (authors, cleaned_extra).
Returns a list of (firstName, lastName) tuples and the extra text
with the Authors line removed.
"""
authors: list[tuple[str, str]] = []
cleaned_lines: list[str] = []
for line in extra.split("\n"):
if line.startswith("Authors:"):
raw = line[8:].strip()
for entry in raw.split(";"):
entry = entry.strip()
if not entry or entry.startswith("(+"):
continue
parts = entry.split(None, 1)
if len(parts) == 2:
authors.append((parts[1].strip(), parts[0].strip()))
elif parts:
authors.append(("", parts[0].strip()))
else:
cleaned_lines.append(line)
return authors, "\n".join(cleaned_lines).strip()
def _add_creators(
con: sqlite3.Connection,
item_id: int,
authors: list[tuple[str, str]],
creator_type_id: int = 10, # 10 = author
) -> int:
"""Add creators to a Zotero item. Returns count added."""
for idx, (first_name, last_name) in enumerate(authors):
creator_id = _ensure_creator(con, first_name, last_name)
con.execute(
"INSERT OR IGNORE INTO itemCreators "
"(itemID, creatorID, creatorTypeID, orderIndex) "
"VALUES (?, ?, ?, ?)",
(item_id, creator_id, creator_type_id, idx),
)
return len(authors)
def _parse_extra_to_journal_fields(extra: str) -> dict[str, str]:
"""Parse structured extra text into Zotero journalArticle fields.
Extracts PMID, DOI, Journal, PubTypes, MeSH from lines like::
PMID: 12345678
DOI: 10.1234/example
Journal: The Lancet
PubTypes: Randomized Controlled Trial; Journal Article
MeSH: Skin Substitutes; Wound Healing
Returns a field dict with proper Zotero field names and a
cleaned ``extra`` containing only the leftover lines.
"""
fields: dict[str, str] = {}
leftover: list[str] = []
for line in extra.split("\n"):
if line.startswith("PMID:"):
fields["PMID"] = line[5:].strip()
elif line.startswith("DOI:"):
fields["DOI"] = line[4:].strip()
elif line.startswith("Journal:"):
fields["publicationTitle"] = line[8:].strip()
elif line.startswith("Authors:"):
pass # handled separately by _parse_authors_from_extra
else:
leftover.append(line)
fields["extra"] = "\n".join(leftover).strip()
return fields
def _item_to_zotero_fields(item: Item) -> dict[str, str]:
"""Convert a bib Item to Zotero EAV field dict."""
ej = json.loads(item.to_row().get("extra_json", "{}"))
@@ -199,10 +312,24 @@ def _item_to_zotero_fields(item: Item) -> dict[str, str]:
"extra": extra,
}
# source (document)
doc_type = ej.get("doc_type", "")
# Journal articles get full bibliographic fields
if doc_type == "journal-article":
fields = _parse_extra_to_journal_fields(item.extra)
fields["title"] = item.title
fields["date"] = item.date_published
fields["url"] = item.url
fields["accessDate"] = item.access_date
fields["abstractNote"] = item.abstract
if item.institution:
fields.setdefault("publicationTitle", item.institution)
return fields
# Generic source (document)
return {
"title": item.title,
"type": ej.get("doc_type", ""),
"type": doc_type,
"publisher": item.institution,
"date": item.date_published,
"url": item.url,
@@ -280,19 +407,21 @@ def push_to_zotero(
stats["skipped"] += 1
continue
type_id = _TYPE_MAP.get(item.item_type)
# Resolve Zotero item type. journal-article sources become
# journalArticle (22); other sources stay document (14).
ej = json.loads(item.to_row().get("extra_json", "{}"))
doc_type = ej.get("doc_type", "")
type_id = _TYPE_MAP.get(doc_type) or _TYPE_MAP.get(item.item_type)
if type_id is None:
stats["skipped"] += 1
continue
# Use the bib key if it looks like a Zotero key (8 alphanum),
# otherwise generate a new one
key = item.key
if not key or len(key) != 8:
key = _zotero_key()
# Only reuse bib key if it passes Zotero's key validation;
# otherwise generate a fresh one.
key = item.key if _is_valid_zotero_key(item.key) else _zotero_key()
# Check key collision
if con.execute("SELECT 1 FROM items WHERE key = ?", (key,)).fetchone():
# Avoid key collision
while con.execute("SELECT 1 FROM items WHERE key = ?", (key,)).fetchone():
key = _zotero_key()
# Insert item
@@ -305,11 +434,20 @@ def push_to_zotero(
)
item_id = cur.lastrowid
# Parse authors from the original item extra (before field
# mapping strips them).
authors, _ = _parse_authors_from_extra(item.extra)
# Set fields via EAV
fields = _item_to_zotero_fields(item)
for field_name, value in fields.items():
_set_field(con, item_id, field_name, value)
# Creators (authors)
if authors:
_add_creators(con, item_id, authors)
stats["creators"] = stats.get("creators", 0) + len(authors)
# Tags
_sync_tags(con, item_id, item.tags)
stats["tags"] += len(item.tags)