Files
stack/dev/scripts/fix_zotero_keys.py
kert 79794ff47a fix Zotero "key is not valid" errors blocking annotation saves
fix #93

2810 items and 10 collections had keys containing characters (0, 1, O)
outside Zotero's allowed set [23456789ABCDEFGHIJKLMNPQRSTUVWXYZ].
These were generated by bulk-import scripts. Zotero's checkKey()
validation rejected them when creating child annotations.

Adds dev/scripts/fix_zotero_keys.py which regenerates invalid keys,
updates the database, and renames storage folders to match.
2026-03-21 23:31:00 -04:00

138 lines
4.0 KiB
Python

"""Fix invalid Zotero object keys.
Zotero 8 requires keys to be exactly 8 characters from
[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]. Bulk-imported items may have
keys containing 0, 1, or O which cause "key is not valid" errors
when Zotero tries to create annotations or other child items.
This script:
1. Finds all items and collections with invalid keys
2. Generates new valid keys (no collisions)
3. Updates the items/collections tables
4. Renames storage folders to match new keys
Usage:
# Stop Zotero first, then:
uv run python dev/scripts/fix_zotero_keys.py
"""
from __future__ import annotations
import os
import random
import re
import shutil
import sqlite3
import sys
ALLOWED = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
KEY_RE = re.compile(rf"^[{ALLOWED}]{{8}}$")
DB_PATH = "zotero/data/zotero.sqlite"
STORAGE_DIR = "zotero/data/storage"
def generate_key(existing: set[str]) -> str:
"""Generate a valid 8-char key not in *existing*."""
while True:
key = "".join(random.choices(ALLOWED, k=8))
if key not in existing:
existing.add(key)
return key
def main() -> int:
if not os.path.exists(DB_PATH):
print(f"ERROR: {DB_PATH} not found", file=sys.stderr)
return 1
# Backup
backup = DB_PATH + ".pre-keyfix.bak"
if not os.path.exists(backup):
shutil.copy2(DB_PATH, backup)
print(f"Backed up to {backup}")
db = sqlite3.connect(DB_PATH, timeout=10)
# Checkpoint WAL first
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
# Collect all existing keys
existing_keys: set[str] = set()
for (k,) in db.execute("SELECT key FROM items"):
existing_keys.add(k)
for (k,) in db.execute("SELECT key FROM collections"):
existing_keys.add(k)
# Find invalid item keys
invalid_items: list[tuple[int, str]] = []
for row in db.execute("SELECT itemID, key FROM items"):
if not KEY_RE.match(row[1]):
invalid_items.append(row)
# Find invalid collection keys
invalid_colls: list[tuple[int, str]] = []
for row in db.execute("SELECT collectionID, key FROM collections"):
if not KEY_RE.match(row[1]):
invalid_colls.append(row)
print(f"Invalid item keys: {len(invalid_items)}")
print(f"Invalid collection keys: {len(invalid_colls)}")
if not invalid_items and not invalid_colls:
print("Nothing to fix!")
db.close()
return 0
# Fix items
renames: list[tuple[str, str]] = [] # (old_key, new_key) for storage
for item_id, old_key in invalid_items:
new_key = generate_key(existing_keys)
db.execute("UPDATE items SET key = ? WHERE itemID = ?", (new_key, item_id))
old_dir = os.path.join(STORAGE_DIR, old_key)
if os.path.isdir(old_dir):
renames.append((old_key, new_key))
# Fix collections
for coll_id, old_key in invalid_colls:
new_key = generate_key(existing_keys)
db.execute(
"UPDATE collections SET key = ? WHERE collectionID = ?",
(new_key, coll_id),
)
db.commit()
db.close()
print(f"Updated {len(invalid_items)} items + {len(invalid_colls)} collections")
# Rename storage folders
renamed = 0
for old_key, new_key in renames:
old_dir = os.path.join(STORAGE_DIR, old_key)
new_dir = os.path.join(STORAGE_DIR, new_key)
if os.path.isdir(old_dir) and not os.path.exists(new_dir):
os.rename(old_dir, new_dir)
renamed += 1
print(f"Renamed {renamed} storage folders")
# Verify
db = sqlite3.connect(DB_PATH, timeout=10)
remaining = 0
for (k,) in db.execute("SELECT key FROM items"):
if not KEY_RE.match(k):
remaining += 1
for (k,) in db.execute("SELECT key FROM collections"):
if not KEY_RE.match(k):
remaining += 1
db.close()
if remaining:
print(f"WARNING: {remaining} invalid keys remaining!")
return 1
print("All keys valid. Restart Zotero to apply.")
return 0
if __name__ == "__main__":
sys.exit(main())