Skill: lyononthemoon/zotero-browse (version 1.0.1)

--- name: zotero-browse description: "Browse, search, and read papers from a local Zotero library. Use when the user wants to: (1) Search their Zotero library by title, author, or keyword, (2) Read or summarize a specific PDF stored in Zotero, (3) Get an overview of their Zotero library (item counts, types, recent additions), (4) Find papers related to a specific topic in their collection. Activates on: 'search my Zotero', 'find papers about', 'read the PDF', 'show recent papers', 'Zotero library summary', 'how many papers do I have on X', any query about accessing or reading Zotero papers or PDFs." metadata: openclaw: emoji: "📚" requires: env: [] bins: ["python3 (with pymupdf, sqlite3 built-in)"] primaryEnv: null --- # Zotero Browse Skill Read papers, search your library, and extract PDF content from a local Zotero database. ## Database & Storage Locations - **Database**: `E:\Refer.Hub\zotero.sqlite` (4300+ items, 2112 stored PDFs) - **PDF storage**: `E:\Refer.Hub\storage\{storageHash}/filename.pdf` - Python stdlib `sqlite3` for queries; `fitz` (PyMuPDF) for PDF reading ## Scripts - `scripts/query_items.py` — Search and browse the library - `scripts/read_pdf.py` — Read PDF text by attachment key or title search Both scripts are executable directly. Always use `py -3` on Windows. --- ## Common Workflows ### 1. Search library by keyword ```bash py -3 scripts/query_items.py --search "FGF15" py -3 scripts/query_items.py --search "fatty liver" ``` Returns: matching items with key, title, authors, date, attachment count. ### 2. Find attachment key for a paper, then read PDF ```bash # Step 1: search to get the attachment key py -3 scripts/query_items.py --search "Silibinin" # Step 2: read the PDF (pass the attachment key shown in output) py -3 scripts/read_pdf.py ZL42EGES ``` ### 3. Read PDF by title search ```bash py -3 scripts/read_pdf.py --search "Silibinin" --pages 5 ``` Prompts for which attachment key to open, then extracts text. ### 4. Library summary ```bash py -3 scripts/query_items.py --summary ``` Shows total items and breakdown by type (journalArticle, book, etc.). ### 5. Recent additions ```bash py -3 scripts/query_items.py --recent 10 ``` ### 6. Get item details by key ```bash py -3 scripts/query_items.py --key ZL42EGES ``` ### 7. Extract full PDF text to file ```bash py -3 scripts/read_pdf.py ZL42EGES --output extracted.txt ``` --- ## Database Schema For SQL query reference (schema, table structure, query examples), see: 📄 `references/schema.md` Key tables: `items`, `itemData`, `fields`, `itemDataValues`, `itemAttachments`, `itemTypes`, `creators`, `itemCreators`, `tags` --- ## PDF Resolution Logic Zotero stores PDFs at `E:\Refer.Hub\storage\{storageHash}/` where `storageHash` is the hash from `itemAttachments.storageHash`. The `itemAttachments.path` field stores the original filename but the folder is named by `storageHash`. **Direct open by key**: ```python import sqlite3, fitz, os DB = r"E:\Refer.Hub\zotero.sqlite" STORAGE = r"E:\Refer.Hub\storage" conn = sqlite3.connect(DB, timeout=30) conn.execute("PRAGMA read_only=ON") cur = conn.cursor() cur.execute(""" SELECT itemAttachments.storageHash FROM itemAttachments JOIN items ON itemAttachments.itemID = items.itemID WHERE items.key = ? AND itemAttachments.linkMode = 0 """, (key,)) row = cur.fetchone() if row and row[0]: folder = os.path.join(STORAGE, row[0]) files = [f for f in os.listdir(folder) if f.lower().endswith('.pdf')] if files: pdf_path = os.path.join(folder, files[0]) doc = fitz.open(pdf_path) text = "\n".join(page.get_text() for page in doc) ``` --- ## Tips - If database is locked, close Zotero application first - For long PDFs, use `--pages N` to extract just first N pages - PDF text extraction works for text-based PDFs; scanned PDFs need OCR - Use `--info` flag to get PDF metadata without extracting full text - Attachment keys are 8-character Zotero item keys shown in search results

path: scripts/query_items.py
content: #!/usr/bin/env python3
"""
Zotero SQLite query script.
Usage: py -3 query_items.py [--db PATH] [--search TERM] [--all] [--summary] [--recent N]
"""

import sqlite3, argparse, sys, os

DEFAULT_DB = r"E:\Refer.Hub\zotero.sqlite"

def get_args():
    parser = argparse.ArgumentParser(description="Query Zotero SQLite database")
    parser.add_argument("--db", default=DEFAULT_DB, help="Path to zotero.sqlite")
    parser.add_argument("--search", help="Search papers by title keyword")
    parser.add_argument("--all", action="store_true", help="List all items")
    parser.add_argument("--summary", action="store_true", help="Show library summary")
    parser.add_argument("--recent", type=int, metavar="N", help="Show N most recent items")
    parser.add_argument("--key", help="Get item by Zotero key")
    return parser.parse_args()

def connect(db_path):
    if not os.path.exists(db_path):
        print(f"ERROR: Database not found: {db_path}", file=sys.stderr)
        print("Your Zotero storage DB is typically at E:\\Refer.Hub\\zotero.sqlite", file=sys.stderr)
        sys.exit(1)
    conn = sqlite3.connect(db_path, timeout=30)
    conn.execute("PRAGMA read_only=ON")
    return conn

def get_field_value(cur, item_id, field_name):
    cur.execute("""
        SELECT itemDataValues.value FROM itemData
        JOIN fields ON itemData.fieldID = fields.fieldID
        JOIN itemDataValues ON itemData.valueID = itemDataValues.valueID
        WHERE itemData.itemID = ? AND fields.fieldName = ?
    """, (item_id, field_name))
    row = cur.fetchone()
    return row[0] if row else ""

def get_attachments(cur, item_id):
    cur.execute("""
        SELECT items.key, itemAttachments.path, itemAttachments.storageHash,
               itemAttachments.linkMode, itemAttachments.contentType
        FROM itemAttachments
        JOIN items ON itemAttachments.itemID = items.itemID
        WHERE itemAttachments.parentItemID = ?
    """, (item_id,))
    return cur.fetchall()

def print_item(cur, item_id, item_key, item_type, verbose=True):
    title = get_field_value(cur, item_id, "title")
    authors = get_field_value(cur, item_id, "creators")
    date = get_field_value(cur, item_id, "date")
    journal = get_field_value(cur, item_id, "publicationTitle")
    doi = get_field_value(cur, item_id, "DOI")
    abstract = get_field_value(cur, item_id, "abstractNote")
    tags = get_field_value(cur, item_id, "tags")
    url = get_field_value(cur, item_id, "url")

    print(f"\n[{item_type}] {item_key}")
    print(f"  Title: {title}")
    if authors: print(f"  Authors: {authors}")
    if date: print(f"  Date: {date}")
    if journal: print(f"  Journal: {journal}")
    if doi: print(f"  DOI: {doi}")
    if abstract: print(f"  Abstract: {abstract[:300]}..." if len(abstract) > 300 else f"  Abstract: {abstract}")
    if tags: print(f"  Tags: {tags}")

    atts = get_attachments(cur, item_id)
    if atts:
        print(f"  Attachments ({len(atts)}):")
        for att_key, path, storage_hash, link_mode, ct in atts:
            mode = {0: "stored", 1: "linked", 2: "imported"}[link_mode] if link_mode in (0,1,2) else f"mode{link_mode}"
            print(f"    [{mode}] key={att_key}, hash={storage_hash[:8] if storage_hash else 'N/A'}")

def cmd_summary(conn):
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) FROM items")
    total = cur.fetchone()[0]
    cur.execute("""
        SELECT itemTypes.typeName, COUNT(*) FROM items
        JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
        GROUP BY items.itemTypeID ORDER BY COUNT(*) DESC
    """)
    rows = cur.fetchall()
    print(f"\n=== Zotero Library Summary ===")
    print(f"Total items: {total}")
    print(f"\nBy type:")
    for name, count in rows:
        print(f"  {name}: {count}")
    print()

def cmd_search(conn, term):
    cur = conn.cursor()
    cur.execute("""
        SELECT items.key, itemTypes.typeName, items.itemID
        FROM items
        JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
        JOIN itemData ON items.itemID = itemData.itemID
        JOIN fields ON itemData.fieldID = fields.fieldID
        JOIN itemDataValues ON itemData.valueID = itemDataValues.valueID
        WHERE fields.fieldName = 'title'
        AND itemDataValues.value LIKE ?
        ORDER BY items.dateAdded DESC
    """, (f"%{term}%",))
    results = cur.fetchall()
    print(f"\n=== Search results for '{term}': {len(results)} found ===\n")
    for key, itype, iid in results:
        title = get_field_value(cur, iid, "title")
        date = get_field_value(cur, iid, "date")
        print(f"[{itype}] {key}")
        print(f"  Title: {title[:100]}..." if len(title) > 100 else f"  Title: {title}")
        if date: print(f"  Date: {date}")
        atts = get_attachments(cur, iid)
        if atts:
            stored = sum(1 for a in atts if a[3] == 0)
            print(f"  Attachments: {len(atts)} ({stored} stored PDF)")
        print()
    return results

def cmd_recent(conn, n):
    cur = conn.cursor()
    cur.execute("""
        SELECT items.key, itemTypes.typeName, items.itemID
        FROM items
        JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
        ORDER BY items.dateAdded DESC LIMIT ?
    """, (n,))
    results = cur.fetchall()
    print(f"\n=== {n} Most Recent Items ===\n")
    for key, itype, iid in results:
        title = get_field_value(cur, iid, "title")
        date = get_field_value(cur, iid, "date")
        print(f"[{itype}] {key} | {date}")
        print(f"  {title[:100]}..." if len(title) > 100 else f"  {title}")
        atts = get_attachments(cur, iid)
        if atts:
            print(f"  Attachments: {len(atts)}")
        print()
    return results

def cmd_all(conn):
    cur = conn.cursor()
    cur.execute("""
        SELECT items.key, itemTypes.typeName, items.itemID
        FROM items
        JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
        ORDER BY items.dateAdded DESC
    """)
    for key, itype, iid in cur:
        title = get_field_value(cur, iid, "title")
        print(f"[{itype}] {key} | {title[:80]}")

def cmd_by_key(conn, key):
    cur = conn.cursor()
    cur.execute("""
        SELECT items.key, itemTypes.typeName, items.itemID
        FROM items JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
        WHERE items.key = ?
    """, (key,))
    row = cur.fetchone()
    if not row:
        print(f"Item not found: {key}")
        return
    k, itype, iid = row
    print_item(cur, iid, k, itype)

def main():
    args = get_args()
    conn = connect(args.db)

    if args.summary:
        cmd_summary(conn)
    elif args.search:
        cmd_search(conn, args.search)
    elif args.recent:
        cmd_recent(conn, args.recent)
    elif args.all:
        cmd_all(conn)
    elif args.key:
        cmd_by_key(conn, args.key)
    else:
        cmd_summary(conn)
        print("Tip: --search TERM  or  --recent N  or  --all  or  --key KEY")

    conn.close()

if __name__ == "__main__":
    main()


path: scripts/read_pdf.py
content: #!/usr/bin/env python3
"""
Read PDF from Zotero storage by attachment key.
Usage: py -3 read_pdf.py <attachment_key> [--db PATH] [--pages N] [--output FILE]
       py -3 read_pdf.py --search "SEARCH TERM" [--db PATH] [--pages N]
"""

import sqlite3, argparse, sys, os

DEFAULT_DB = r"E:\Refer.Hub\zotero.sqlite"
STORAGE_BASE = r"E:\Refer.Hub\storage"

def get_args():
    parser = argparse.ArgumentParser(description="Read PDF from Zotero storage")
    parser.add_argument("key", nargs="?", help="Attachment key (e.g. ZL42EGES)")
    parser.add_argument("--search", "-s", help="Search for PDF by paper title keyword")
    parser.add_argument("--db", default=DEFAULT_DB, help="Path to zotero.sqlite")
    parser.add_argument("--pages", "-p", type=int, default=0,
                        help="Max pages to extract (0=all)")
    parser.add_argument("--output", "-o", help="Write extracted text to file")
    parser.add_argument("--info", "-i", action="store_true", help="Show PDF info only")
    return parser.parse_args()

def get_pdf_path(cur, key):
    """Get PDF file path from attachment key."""
    cur.execute("""
        SELECT items.key, itemAttachments.path, itemAttachments.storageHash
        FROM itemAttachments
        JOIN items ON itemAttachments.itemID = items.itemID
        WHERE items.key = ? AND itemAttachments.linkMode = 0
    """, (key,))
    row = cur.fetchone()
    if not row:
        return None, None
    att_key, path, storage_hash = row
    if not storage_hash:
        return None, path
    folder = os.path.join(STORAGE_BASE, storage_hash)
    if os.path.isdir(folder):
        files = [f for f in os.listdir(folder) if f.lower().endswith(".pdf")]
        if files:
            return os.path.join(folder, files[0]), path
    return None, path

def find_pdf_by_title(cur, term):
    """Search for PDF attachment by paper title."""
    cur.execute("""
        SELECT items.key, itemTypes.typeName, items.itemID,
               itemDataValues.value
        FROM items
        JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
        JOIN itemData ON items.itemID = itemData.itemID
        JOIN fields ON itemData.fieldID = fields.fieldID
        JOIN itemDataValues ON itemData.valueID = itemDataValues.valueID
        WHERE fields.fieldName = 'title'
        AND itemDataValues.value LIKE ?
        ORDER BY items.dateAdded DESC
    """, (f"%{term}%",))
    results = cur.fetchall()
    return results

def get_pdf_info(pdf_path):
    """Get PDF metadata using PyMuPDF."""
    import fitz
    doc = fitz.open(pdf_path)
    info = {
        "pages": len(doc),
        "title": doc.metadata.get("title", ""),
        "author": doc.metadata.get("author", ""),
        "subject": doc.metadata.get("subject", ""),
        "file_size": os.path.getsize(pdf_path),
    }
    return info, doc

def extract_text(pdf_path, max_pages=0):
    """Extract text from PDF using PyMuPDF."""
    import fitz
    doc = fitz.open(pdf_path)
    total = len(doc)
    limit = min(max_pages, total) if max_pages > 0 else total

    full_text = ""
    for i in range(limit):
        text = doc[i].get_text()
        full_text += f"\n=== Page {i+1}/{total} ===\n{text}"

    if max_pages == 0 or max_pages >= total:
        full_text += f"\n[Truncated at page {limit}/{total}]"
    doc.close()
    return full_text

def main():
    args = get_args()
    conn = sqlite3.connect(args.db, timeout=30)
    conn.execute("PRAGMA read_only=ON")

    if args.search:
        print(f"Searching for: {args.search}")
        results = find_pdf_by_title(conn, args.search)
        print(f"Found {len(results)} papers:\n")
        for key, itype, iid, title in results:
            print(f"  [{key}] {title[:80]}...")
        print()
        if not results:
            conn.close()
            return
        att_key = input("Enter attachment key to open: ").strip()
    else:
        att_key = args.key

    if not att_key:
        print("Usage: py -3 read_pdf.py <key>  or  py -3 read_pdf.py --search 'TERM'")
        conn.close()
        return

    pdf_path, db_path = get_pdf_path(conn.cursor(), att_key)
    conn.close()

    if not pdf_path:
        # Try finding by title in storage folder name
        print(f"PDF path not resolved for key={att_key}")
        print(f"DB path record: {db_path}")
        print("This may be a linked file, not a stored attachment.")
        return

    print(f"Opening: {pdf_path}")
    print(f"File size: {os.path.getsize(pdf_path) / 1024 / 1024:.1f} MB\n")

    if args.info:
        info, doc = get_pdf_info(pdf_path)
        print(f"Pages: {info['pages']}")
        print(f"Title: {info['title']}")
        print(f"Author: {info['author']}")
        print(f"Subject: {info['subject']}")
        return

    print(f"Extracting text (max_pages={args.pages or 'all'})...")
    text = extract_text(pdf_path, args.pages)

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(text)
        print(f"Text written to: {args.output}")
    else:
        print(text)

if __name__ == "__main__":
    main()


path: references/schema.md
content: # Zotero SQLite Database Schema

## Primary Database Location

```
E:\Refer.Hub\zotero.sqlite   (main library, ~223 MB)
```

The database is also at `C:\Users\41406\Zotero\zotero.sqlite` but `E:\Refer.Hub` contains the full indexed library.

## Key Tables

### items — All library entries
| Column | Type | Description |
|--------|------|-------------|
| itemID | INTEGER | Primary key |
| itemTypeID | INT | Type of item (journalArticle, book, etc.) |
| key | TEXT | Zotero item key (8-char alphanumeric, e.g. `ZL42EGES`) |
| libraryID | INT | Library (personal=1 usually) |
| dateAdded | TIMESTAMP | When added |
| dateModified | TIMESTAMP | Last modified |
| synced | INT | Sync state |

### itemTypes
| Column | Type |
|--------|------|
| itemTypeID | INTEGER |
| typeName | TEXT (journalArticle, book, bookSection, conferencePaper, attachment, note...) |

### itemData — All field values (linked table)
| Column | Type |
|--------|------|
| itemID | INT |
| fieldID | INT |
| valueID | INT |

### fields — Field definitions
| fieldID | fieldName |
|---------|-----------|
| ... | title |
| ... | creators |
| ... | date |
| ... | publicationTitle |
| ... | journalAbbreviation |
| ... | DOI |
| ... | abstractNote |
| ... | tags |
| ... | url |
| ... | volume |
| ... | issue |
| ... | pages |
| ... | publisher |
| ... | ISBN |
| ... | ISSN |

### itemDataValues — Actual text values
| Column | Type |
|--------|------|
| valueID | INTEGER |
| value | TEXT |

### itemAttachments — PDF/linked files
| Column | Type | Description |
|--------|------|-------------|
| itemID | INTEGER | Attachment item ID |
| parentItemID | INT | Parent literature item ID |
| linkMode | INT | 0=stored file, 1=linked file, 2=imported file |
| path | TEXT | Path string like `storage:filename.pdf` |
| storageHash | TEXT | Hash of attachment content, matches folder name in `storage/` |
| contentType | TEXT | MIME type (e.g. `application/pdf`) |

### itemCreators — Author/creator links
| Column | Type |
|--------|------|
| itemID | INT |
| creatorID | INT |
| creatorTypeID | INT (author, editor, etc.) |

### creators — Creator details
| Column | Type |
|--------|------|
| creatorID | INTEGER |
| firstName | TEXT |
| lastName | TEXT |

### tags
| Column | Type |
|--------|------|
| name | TEXT |
| type | INT |

### itemTags — Tag associations
| Column | Type |
|--------|------|
| itemID | INT |
| tagID | INT |

### collections — Folders/collections
| Column | Type |
|--------|------|
| name | TEXT |
| parentCollectionID | INT |

## Common Query Patterns

### Search papers by title
```sql
SELECT items.key, itemTypes.typeName, itemDataValues.value AS title
FROM items
JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
JOIN itemData ON items.itemID = itemData.itemID
JOIN fields ON itemData.fieldID = fields.fieldID
JOIN itemDataValues ON itemData.valueID = itemDataValues.valueID
WHERE fields.fieldName = 'title'
AND itemDataValues.value LIKE '%NAFLD%'
ORDER BY items.dateAdded DESC
```

### Get authors of an item
```sql
SELECT creators.firstName, creators.lastName, creatorTypes.creatorType
FROM itemCreators
JOIN creators ON itemCreators.creatorID = creators.creatorID
JOIN creatorTypes ON itemCreators.creatorTypeID = creatorTypes.creatorTypeID
WHERE itemCreators.itemID = ?
```

### Find PDF path for an attachment key
```sql
SELECT items.key, itemAttachments.path, itemAttachments.storageHash
FROM itemAttachments
JOIN items ON itemAttachments.itemID = items.itemID
WHERE items.key = 'ZL42EGES' AND itemAttachments.linkMode = 0
```

### Get recent items with tags
```sql
SELECT items.key, itemTypes.typeName, itemDataValues.value AS title, items.dateAdded
FROM items
JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
JOIN itemData ON items.itemID = itemData.itemID
JOIN fields ON itemData.fieldID = fields.fieldID
JOIN itemDataValues ON itemData.valueID = itemDataValues.valueID
WHERE fields.fieldName = 'title'
ORDER BY items.dateAdded DESC LIMIT 20
```

## Storage Structure

PDFs are stored in `E:\Refer.Hub\storage\{storageHash}/` (one folder per attachment).
Folder name = `storageHash` value from `itemAttachments` table.

```
E:\Refer.Hub\
├── zotero.sqlite          ← main database
└── storage\
    ├── ZL42EGES\          ← folder named by storageHash
    │   ├── .zotero-ft-cache
    │   ├── .zotero-reader-state
    │   └── Full Paper Title.pdf
    ├── M6BCAS9M\
    └── ...
```

To resolve the actual PDF path:
1. Query `itemAttachments` with attachment key → get `storageHash`
2. The folder `E:\Refer.Hub\storage\{storageHash}\` contains the PDF
3. List folder, pick the `.pdf` file

## Python Quick Reference

```python
import sqlite3, fitz, os

DB = r"E:\Refer.Hub\zotero.sqlite"
STORAGE = r"E:\Refer.Hub\storage"

conn = sqlite3.connect(DB, timeout=30)
conn.execute("PRAGMA read_only=ON")
cur = conn.cursor()

# Search
cur.execute("""
    SELECT items.key, itemTypes.typeName, itemDataValues.value
    FROM items JOIN itemTypes ON items.itemTypeID = itemTypes.itemTypeID
    JOIN itemData ON items.itemID = itemData.itemID
    JOIN fields ON itemData.fieldID = fields.fieldID
    JOIN itemDataValues ON itemData.valueID = itemDataValues.valueID
    WHERE fields.fieldName = 'title' AND itemDataValues.value LIKE ?
""", (f"%{term}%",))

# Read PDF
import fitz
doc = fitz.open(pdf_path)
text = "\n".join(page.get_text() for page in doc)
```

## Limitations

- Database is locked by Zotero when it is running → use read-only mode
- PDF text extraction via PyMuPDF works for text-based PDFs
- Scanned/image PDFs require OCR (not covered here)
- Writing to database requires closing Zotero and using mode=OFF


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)