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.
138 lines
4.0 KiB
Python
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())
|