Files
stack/notebooks/zotero_tutorial.py
kert e8f6385bc5
All checks were successful
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m24s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 48s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Successful in 12s
Infra CI / api (push) Successful in 17s
Infra CI / mc (push) Successful in 21s
Deploy / report (push) Successful in 14s
CI / test (push) Successful in 16m35s
fix(notebooks): api_explorer target URL + zotero_tutorial polars schema inference
- api_explorer pointed at http://localhost:8080 — a host-side dev
  server that never exists inside the notebooks container (nb issue
  #546 alongside the api service wedge). Now defaults to the compose
  service http://api:8000, overridable via STACK_API_URL.
- zotero_tutorial's CASE/GROUP_CONCAT queries can yield columns that
  are all-NULL within polars' default inference window with strings
  appearing later ('could not append value', nb issue #561 — surfaced
  once the immutable=1 fix got reads past the lock). Pin
  infer_schema_length=None on all read_database calls.

Verified headless in the prod container: both notebooks now export
with zero cell errors.
2026-07-10 14:17:16 -04:00

641 lines
18 KiB
Python

import marimo
__generated_with = "0.19.8"
app = marimo.App(width="medium")
@app.cell(hide_code=True)
def _():
import marimo as mo
mo.md("""
# Zotero Library Explorer
Inspect and work with the Zotero library running in the local `zotero` container.
This notebook provides two modes of access:
- **Local SQLite** — reads the Zotero database directly from the path configured in `stack.toml`
- **Web API via pyzotero** — connects to `api.zotero.org` when API credentials are configured
The local SQLite path works immediately with the container's library.
The Web API requires a [Zotero API key](https://www.zotero.org/settings/keys/new).
""")
return (mo,)
@app.cell(hide_code=True)
def _(mo):
import sqlite3
import polars as pl
from pyzotero import zotero
from conf import connect
from conf import path as _conf_path
ZOTERO_DB = _conf_path("db.zotero")
mo.md(f"""
## 1. Local Database Connection
Database path: `{ZOTERO_DB}`
Exists: **{ZOTERO_DB.exists()}**
""")
return ZOTERO_DB, connect, pl, sqlite3, zotero
@app.cell(hide_code=True)
def _(connect, sqlite3):
db = connect.zotero()
db.row_factory = sqlite3.Row
# Quick health check
tables = [
r[0]
for r in db.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
]
print(f"Connected — {len(tables)} tables found")
print(f"Tables: {', '.join(tables[:15])}{'...' if len(tables) > 15 else ''}")
return (db,)
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 2. Library Overview
Top-level statistics about what's in the Zotero library.
""")
return
@app.cell(hide_code=True)
def _(db, mo):
stats = {}
stats["Items"] = db.execute(
"SELECT COUNT(*) FROM items WHERE itemID NOT IN (SELECT itemID FROM deletedItems)"
).fetchone()[0]
stats["Collections"] = db.execute("SELECT COUNT(*) FROM collections").fetchone()[0]
stats["Tags"] = db.execute("SELECT COUNT(DISTINCT name) FROM tags").fetchone()[0]
stats["Creators"] = db.execute("SELECT COUNT(*) FROM creators").fetchone()[0]
stats["Attachments"] = db.execute(
"SELECT COUNT(*) FROM itemAttachments WHERE parentItemID IS NOT NULL"
).fetchone()[0]
stat_table = "\n".join(f"| {k} | {v} |" for k, v in stats.items())
mo.md(f"""
| Metric | Count |
|--------|-------|
{stat_table}
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 3. Browse Collections
The collection hierarchy in the library.
""")
return
@app.cell(hide_code=True)
def _(db, mo, pl):
collections_df = pl.read_database(
"""
SELECT
c.collectionID,
c.collectionName,
c.parentCollectionID,
(SELECT COUNT(*) FROM collectionItems ci WHERE ci.collectionID = c.collectionID) AS itemCount
FROM collections c
ORDER BY c.collectionName
""",
connection=db,
infer_schema_length=None,
)
def _build_tree(df, parent_id=None, depth=0):
_rows = df.filter(
pl.col("parentCollectionID") == parent_id
if parent_id
else pl.col("parentCollectionID").is_null()
)
_lines = []
for _row in _rows.iter_rows(named=True):
_indent = " " * depth
_lines.append(
f"{_indent}- **{_row['collectionName']}** ({_row['itemCount']} items)"
)
_lines.extend(_build_tree(df, _row["collectionID"], depth + 1))
return _lines
_tree = _build_tree(collections_df) if collections_df.height > 0 else []
mo.md(
"\n".join(_tree)
if _tree
else "No collections found. Add some in the Zotero desktop app."
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 4. Item Types
Breakdown of items by type (journal article, book, webpage, etc.).
""")
return
@app.cell(hide_code=True)
def _(db, mo, pl):
item_types_df = pl.read_database(
"""
SELECT
it.typeName,
COUNT(*) as count
FROM items i
JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
GROUP BY it.typeName
ORDER BY count DESC
""",
connection=db,
infer_schema_length=None,
)
mo.ui.table(
item_types_df, label="Item Types"
) if item_types_df.height > 0 else mo.md("No library items found yet.")
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 5. Recent Items
The most recently added items in the library.
""")
return
@app.cell(hide_code=True)
def _(db, mo, pl):
recent_df = pl.read_database(
"""
SELECT
i.itemID,
it.typeName AS type,
MAX(CASE WHEN f.fieldName = 'title' THEN idv.value END) AS title,
MAX(CASE WHEN f.fieldName = 'date' THEN idv.value END) AS date,
MAX(CASE WHEN f.fieldName = 'DOI' THEN idv.value END) AS doi,
MAX(CASE WHEN f.fieldName = 'url' THEN idv.value END) AS url,
i.dateAdded
FROM items i
JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
LEFT JOIN itemData id ON i.itemID = id.itemID
LEFT JOIN fields f ON id.fieldID = f.fieldID
LEFT JOIN itemDataValues idv ON id.valueID = idv.valueID
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
GROUP BY i.itemID
ORDER BY i.dateAdded DESC
LIMIT 25
""",
connection=db,
infer_schema_length=None,
)
mo.ui.table(recent_df, label="Recent Items") if recent_df.height > 0 else mo.md(
"No items found. Add references through the Zotero desktop app (VNC)."
)
return (recent_df,)
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 6. Search Items
Search across titles, creators, tags, and notes.
""")
return
@app.cell(hide_code=True)
def _(mo):
search_input = mo.ui.text(
placeholder="Enter search terms...", label="Search", full_width=True
)
search_input
return (search_input,)
@app.cell(hide_code=True)
def _(db, mo, pl, search_input):
_query = search_input.value.strip() if search_input.value else ""
if not _query:
mo.md("Type a search query above.")
mo.stop(True)
_search_results = pl.read_database(
"""
SELECT DISTINCT
i.itemID,
it.typeName AS type,
MAX(CASE WHEN f.fieldName = 'title' THEN idv.value END) AS title,
MAX(CASE WHEN f.fieldName = 'date' THEN idv.value END) AS date,
GROUP_CONCAT(DISTINCT c.lastName) AS authors
FROM items i
JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
LEFT JOIN itemData id ON i.itemID = id.itemID
LEFT JOIN fields f ON id.fieldID = f.fieldID
LEFT JOIN itemDataValues idv ON id.valueID = idv.valueID
LEFT JOIN itemCreators ic ON i.itemID = ic.itemID
LEFT JOIN creators c ON ic.creatorID = c.creatorID
LEFT JOIN itemTags itag ON i.itemID = itag.itemID
LEFT JOIN tags t ON itag.tagID = t.tagID
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
AND (
idv.value LIKE '%' || :q || '%'
OR c.lastName LIKE '%' || :q || '%'
OR c.firstName LIKE '%' || :q || '%'
OR t.name LIKE '%' || :q || '%'
)
GROUP BY i.itemID
ORDER BY i.dateAdded DESC
LIMIT 50
""",
connection=db,
execute_options={"parameters": {"q": _query}},
)
mo.ui.table(
_search_results, label=f"Results for '{_query}'"
) if _search_results.height > 0 else mo.md(f"No results for **{_query}**")
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 7. Item Detail Inspector
Pick an item ID from the tables above to see its full metadata, creators, tags, and attachments.
""")
return
@app.cell(hide_code=True)
def _(mo, recent_df):
default_id = str(recent_df["itemID"][0]) if recent_df.height > 0 else ""
item_id_input = mo.ui.text(value=default_id, label="Item ID", full_width=False)
item_id_input
return (item_id_input,)
@app.cell(hide_code=True)
def _(db, item_id_input, mo):
item_id = item_id_input.value.strip()
if not item_id or not item_id.isdigit():
mo.md("Enter a numeric item ID above.")
mo.stop(True)
iid = int(item_id)
# Metadata fields
fields = db.execute(
"""
SELECT f.fieldName, idv.value
FROM itemData id
JOIN fields f ON id.fieldID = f.fieldID
JOIN itemDataValues idv ON id.valueID = idv.valueID
WHERE id.itemID = ?
ORDER BY f.fieldName
""",
(iid,),
).fetchall()
# Creators
_creators = db.execute(
"""
SELECT c.firstName, c.lastName, ct.creatorType, ic.orderIndex
FROM itemCreators ic
JOIN creators c ON ic.creatorID = c.creatorID
JOIN creatorTypes ct ON ic.creatorTypeID = ct.creatorTypeID
WHERE ic.itemID = ?
ORDER BY ic.orderIndex
""",
(iid,),
).fetchall()
# Tags
_tags = db.execute(
"""
SELECT t.name, CASE itag.type WHEN 0 THEN 'manual' ELSE 'automatic' END AS source
FROM itemTags itag
JOIN tags t ON itag.tagID = t.tagID
WHERE itag.itemID = ?
ORDER BY t.name
""",
(iid,),
).fetchall()
# Attachments
_attachments = db.execute(
"""
SELECT ia.path, ia.contentType, i.key
FROM itemAttachments ia
JOIN items i ON ia.itemID = i.itemID
WHERE ia.parentItemID = ?
""",
(iid,),
).fetchall()
_sections = [f"### Item {iid}\n"]
if fields:
_sections.append("**Metadata**\n")
_sections.append("| Field | Value |")
_sections.append("|-------|-------|")
for _f in fields:
_val = str(_f["value"])[:120]
_sections.append(f"| {_f['fieldName']} | {_val} |")
if _creators:
_sections.append("\n**Creators**\n")
for _c in _creators:
_sections.append(
f"- {_c['firstName']} {_c['lastName']} ({_c['creatorType']})"
)
if _tags:
_sections.append("\n**Tags**\n")
_sections.append(", ".join(f"`{_t['name']}`" for _t in _tags))
if _attachments:
_sections.append("\n**Attachments**\n")
for _a in _attachments:
_path = _a["path"] or "(linked)"
_sections.append(f"- `{_path}` ({_a['contentType'] or 'unknown'})")
mo.md("\n".join(_sections))
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 8. Tags Overview
All tags in the library with usage counts.
""")
return
@app.cell(hide_code=True)
def _(db, mo, pl):
tags_df = pl.read_database(
"""
SELECT t.name AS tag, COUNT(*) AS count
FROM itemTags itag
JOIN tags t ON itag.tagID = t.tagID
JOIN items i ON itag.itemID = i.itemID
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
GROUP BY t.name
ORDER BY count DESC
""",
connection=db,
infer_schema_length=None,
)
mo.ui.table(tags_df, label="Tags") if tags_df.height > 0 else mo.md(
"No tags found."
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 9. Creator Network
Most prolific authors/creators in the library.
""")
return
@app.cell(hide_code=True)
def _(db, mo, pl):
creators_df = pl.read_database(
"""
SELECT
c.firstName || ' ' || c.lastName AS name,
ct.creatorType,
COUNT(DISTINCT ic.itemID) AS items
FROM itemCreators ic
JOIN creators c ON ic.creatorID = c.creatorID
JOIN creatorTypes ct ON ic.creatorTypeID = ct.creatorTypeID
JOIN items i ON ic.itemID = i.itemID
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
GROUP BY c.creatorID, ct.creatorType
ORDER BY items DESC
LIMIT 50
""",
connection=db,
infer_schema_length=None,
)
mo.ui.table(creators_df, label="Top Creators") if creators_df.height > 0 else mo.md(
"No creators found."
)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 10. Export to DataFrame
Full library export as a Polars DataFrame for further analysis.
""")
return
@app.cell(hide_code=True)
def _(db, mo, pl):
library_df = pl.read_database(
"""
SELECT
i.itemID,
i.key,
it.typeName AS itemType,
MAX(CASE WHEN f.fieldName = 'title' THEN idv.value END) AS title,
MAX(CASE WHEN f.fieldName = 'date' THEN idv.value END) AS date,
MAX(CASE WHEN f.fieldName = 'DOI' THEN idv.value END) AS doi,
MAX(CASE WHEN f.fieldName = 'url' THEN idv.value END) AS url,
MAX(CASE WHEN f.fieldName = 'abstractNote' THEN idv.value END) AS abstract,
MAX(CASE WHEN f.fieldName = 'publicationTitle' THEN idv.value END) AS publication,
GROUP_CONCAT(DISTINCT c.lastName) AS authors,
GROUP_CONCAT(DISTINCT t.name) AS tags,
i.dateAdded,
i.dateModified
FROM items i
JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
LEFT JOIN itemData id ON i.itemID = id.itemID
LEFT JOIN fields f ON id.fieldID = f.fieldID
LEFT JOIN itemDataValues idv ON id.valueID = idv.valueID
LEFT JOIN itemCreators ic ON i.itemID = ic.itemID
LEFT JOIN creators c ON ic.creatorID = c.creatorID
LEFT JOIN itemTags itag ON i.itemID = itag.itemID
LEFT JOIN tags t ON itag.tagID = t.tagID
WHERE i.itemID NOT IN (SELECT itemID FROM deletedItems)
AND it.typeName NOT IN ('attachment', 'note', 'annotation')
GROUP BY i.itemID
ORDER BY i.dateAdded DESC
""",
connection=db,
infer_schema_length=None,
)
mo.md(f"Exported **{library_df.height}** items to `library_df`")
return (library_df,)
@app.cell(hide_code=True)
def _(library_df, mo):
mo.ui.dataframe(library_df)
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 11. Pyzotero Web API (Optional)
If your Zotero library syncs to zotero.org, you can use pyzotero for read/write operations.
1. Go to [zotero.org/settings/keys/new](https://www.zotero.org/settings/keys/new) and create an API key
2. Find your library ID at [zotero.org/settings/keys](https://www.zotero.org/settings/keys) (it's the number in the "Your userID" section)
3. Enter them below
""")
return
@app.cell(hide_code=True)
def _(mo):
api_key_input = mo.ui.text(
placeholder="Zotero API key", label="API Key", kind="password", full_width=True
)
library_id_input = mo.ui.text(
placeholder="Library ID (numeric)", label="Library ID", full_width=True
)
lib_type_input = mo.ui.dropdown(
options=["user", "group"], value="user", label="Library Type"
)
mo.hstack([library_id_input, lib_type_input, api_key_input], widths=[1, 1, 2])
return api_key_input, lib_type_input, library_id_input
@app.cell(hide_code=True)
def _(api_key_input, lib_type_input, library_id_input, mo, zotero):
api_key = api_key_input.value.strip() if api_key_input.value else ""
lib_id = library_id_input.value.strip() if library_id_input.value else ""
lib_type = lib_type_input.value
if not api_key or not lib_id:
mo.md(
"Enter your Zotero API key and library ID above to enable Web API access."
)
mo.stop(True)
zot = zotero.Zotero(lib_id, lib_type, api_key)
# Test the connection
_conn_error = None
try:
_key_info = zot.key_info()
except Exception as e:
_conn_error = str(e)
if _conn_error:
mo.md(f"Connection failed: `{_conn_error}`")
mo.stop(True)
mo.md(f"""
Connected to Zotero Web API
- **User**: {_key_info.get("userID", "N/A")}
- **Key name**: {_key_info.get("key", "N/A")[:8]}...
- **Access**: {"read/write" if _key_info.get("access", {}).get("user", {}).get("library") else "read-only"}
""")
return (zot,)
@app.cell(hide_code=True)
def _(mo, zot):
mo.md("### Web API — Top 10 Recent Items")
_top_items = zot.top(limit=10)
if _top_items:
for _item in _top_items:
_data = _item["data"]
_title = _data.get("title", "(untitled)")
_item_type = _data.get("itemType", "?")
_authors = ", ".join(
_cr.get("lastName", _cr.get("name", "?"))
for _cr in _data.get("creators", [])
)
print(f"[{_item_type}] {_title}")
if _authors:
print(f" by {_authors}")
else:
print("No items in library.")
return
@app.cell(hide_code=True)
def _(mo, zot):
mo.md("### Web API — Collections")
_collections = zot.collections()
for _col in _collections:
_name = _col["data"]["name"]
_count = _col["meta"].get("numItems", 0)
print(f" {_name} ({_count} items)")
if not _collections:
print("No collections.")
return
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## Summary
| Approach | When to use |
|----------|-------------|
| Local SQLite | Always available, read-only, fast, no credentials needed |
| Pyzotero Web API | Read/write, sync support, requires zotero.org API key |
The local database is mounted read-only from the `zotero` container at the path configured in `stack.toml` (`db.zotero`).
Changes made through the VNC desktop app are reflected here on next Zotero save.
""")
return
if __name__ == "__main__":
app.run()