Files
stack/dev/scripts/zotero_archive_migrate.py
kert 16f3b43974
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m12s
CI / skinny-install (api) (push) Successful in 30s
CI / skinny-install (bcda) (push) Successful in 36s
CI / skinny-install (bib) (push) Successful in 35s
CI / skinny-install (bls) (push) Successful in 27s
CI / skinny-install (ccw) (push) Successful in 32s
CI / skinny-install (cli) (push) Successful in 41s
CI / skinny-install (cms) (push) Successful in 37s
CI / skinny-install (conf) (push) Successful in 38s
CI / skinny-install (opps) (push) Successful in 33s
CI / skinny-install (perf) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 38s
CI / skinny-install (rex) (push) Successful in 34s
Deploy / build-scan-report (push) Failing after 46s
Infra CI / notebooks (push) Failing after 25s
Infra CI / zotero (push) Successful in 12s
Infra CI / docs (push) Failing after 16s
CI / lint-test (push) Failing after 11m2s
Infra CI / mc (push) Successful in 21s
Infra CI / api (push) Successful in 29s
Package Supply Chain / pkg-supply-chain (push) Failing after 41s
feat: full session — mail servers, comment pipeline, PRISMA fetch, email ingest
Mail: Maddy on DO (corwins.media+Resend, fhirworx.io+Postmark),
touchless/stateless/idempotent. Gitea SMTP via env_file. CMS inbox
at cmsupdates@mail.fhirworx.io with IMAP→bib poller.

Bib: regulations.gov v4 client, Federal Register discovery, 164K
comment backfill (running), IMAP email ingest, Zotero sync routing.

PRISMA: altcha PoW solver, CrossRef DOI resolution, 83/129 PDFs.
Zotero: schema parity, ops module, CLI, fail-fast guard.
CI: docs.Dockerfile COPY glob fix (tracks #341).
Infra: Gitea+marimo fhirworx themes, IOM/OIG modules.
2026-04-16 09:04:38 -04:00

456 lines
17 KiB
Python

"""Migrate host Zotero library into container Zotero under an 'archive' collection.
Creates new keys for all items, remaps all IDs, copies storage directories.
Must be run with Zotero process stopped.
"""
import shutil
import sqlite3
import sys
from pathlib import Path
from zot.db import generate_key
HOST_DB = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/host-zotero.sqlite")
CONTAINER_DB = (
Path(sys.argv[2])
if len(sys.argv) > 2
else Path("/home/ubuntu/Zotero/zotero.sqlite")
)
HOST_STORAGE = (
Path(sys.argv[3]) if len(sys.argv) > 3 else Path("/home/ubuntu/data/Zotero/storage")
)
CONTAINER_STORAGE = (
Path(sys.argv[4]) if len(sys.argv) > 4 else Path("/home/ubuntu/Zotero/storage")
)
LIBRARY_ID = 1 # user library
def gen_key(existing: set[str]) -> str:
while True:
k = generate_key()
if k not in existing:
existing.add(k)
return k
def main():
if not HOST_DB.exists():
sys.exit(f"Host DB not found: {HOST_DB}")
if not CONTAINER_DB.exists():
sys.exit(f"Container DB not found: {CONTAINER_DB}")
src = sqlite3.connect(str(HOST_DB))
src.row_factory = sqlite3.Row
dst = sqlite3.connect(str(CONTAINER_DB))
dst.row_factory = sqlite3.Row
dst.execute("PRAGMA journal_mode=WAL")
dst.execute("PRAGMA foreign_keys=OFF") # we handle ordering ourselves
# Collect existing keys in destination
existing_keys = {
r[0]
for r in dst.execute("SELECT key FROM items WHERE libraryID=?", (LIBRARY_ID,))
}
existing_keys |= {
r[0]
for r in dst.execute(
"SELECT key FROM collections WHERE libraryID=?", (LIBRARY_ID,)
)
}
# --- Max IDs in destination ---
max_item_id = dst.execute("SELECT COALESCE(MAX(itemID),0) FROM items").fetchone()[0]
max_coll_id = dst.execute(
"SELECT COALESCE(MAX(collectionID),0) FROM collections"
).fetchone()[0]
max_value_id = dst.execute(
"SELECT COALESCE(MAX(valueID),0) FROM itemDataValues"
).fetchone()[0]
max_creator_id = dst.execute(
"SELECT COALESCE(MAX(creatorID),0) FROM creators"
).fetchone()[0]
max_tag_id = dst.execute("SELECT COALESCE(MAX(tagID),0) FROM tags").fetchone()[0]
max_word_id = dst.execute(
"SELECT COALESCE(MAX(wordID),0) FROM fulltextWords"
).fetchone()[0]
# ============================================================
# 1. Create "archive" collection + mirror host collection tree
# ============================================================
archive_key = gen_key(existing_keys)
max_coll_id += 1
archive_coll_id = max_coll_id
dst.execute(
"INSERT INTO collections (collectionID, collectionName, parentCollectionID, libraryID, key, version, synced) "
"VALUES (?,?,NULL,?,?,0,0)",
(archive_coll_id, "archive", LIBRARY_ID, archive_key),
)
print(f"Created 'archive' collection: ID={archive_coll_id}, key={archive_key}")
# Mirror host collections under archive
host_colls = src.execute(
"SELECT collectionID, collectionName, parentCollectionID FROM collections ORDER BY collectionID"
).fetchall()
coll_id_map: dict[int, int] = {} # host collectionID -> dest collectionID
for hc in host_colls:
max_coll_id += 1
new_id = max_coll_id
coll_id_map[hc["collectionID"]] = new_id
# parent: if host has a parent, map it; otherwise parent is archive
if hc["parentCollectionID"] is not None:
parent = coll_id_map.get(hc["parentCollectionID"], archive_coll_id)
else:
parent = archive_coll_id
coll_key = gen_key(existing_keys)
dst.execute(
"INSERT INTO collections (collectionID, collectionName, parentCollectionID, libraryID, key, version, synced) "
"VALUES (?,?,?,?,?,0,0)",
(new_id, hc["collectionName"], parent, LIBRARY_ID, coll_key),
)
print(f"Mirrored {len(host_colls)} host collections under archive")
# ============================================================
# 2. Migrate itemDataValues (shared, deduplicated by value)
# ============================================================
# Build lookup of existing values in dest
dst_values: dict[str, int] = {}
for r in dst.execute("SELECT valueID, value FROM itemDataValues"):
dst_values[str(r["value"])] = r["valueID"]
src_value_map: dict[int, int] = {} # host valueID -> dest valueID
for r in src.execute("SELECT valueID, value FROM itemDataValues"):
val = str(r["value"])
if val in dst_values:
src_value_map[r["valueID"]] = dst_values[val]
else:
max_value_id += 1
dst.execute(
"INSERT INTO itemDataValues (valueID, value) VALUES (?,?)",
(max_value_id, r["value"]),
)
dst_values[val] = max_value_id
src_value_map[r["valueID"]] = max_value_id
print(f"Mapped {len(src_value_map)} itemDataValues")
# ============================================================
# 3. Migrate creators (deduplicated by lastName+firstName+fieldMode)
# ============================================================
dst_creators: dict[tuple, int] = {}
for r in dst.execute(
"SELECT creatorID, firstName, lastName, fieldMode FROM creators"
):
dst_creators[(r["lastName"], r["firstName"], r["fieldMode"])] = r["creatorID"]
src_creator_map: dict[int, int] = {}
for r in src.execute(
"SELECT creatorID, firstName, lastName, fieldMode FROM creators"
):
ck = (r["lastName"], r["firstName"], r["fieldMode"])
if ck in dst_creators:
src_creator_map[r["creatorID"]] = dst_creators[ck]
else:
max_creator_id += 1
dst.execute(
"INSERT INTO creators (creatorID, firstName, lastName, fieldMode) VALUES (?,?,?,?)",
(max_creator_id, r["firstName"], r["lastName"], r["fieldMode"]),
)
dst_creators[ck] = max_creator_id
src_creator_map[r["creatorID"]] = max_creator_id
print(f"Mapped {len(src_creator_map)} creators")
# ============================================================
# 4. Migrate tags (deduplicated by name)
# ============================================================
dst_tags: dict[str, int] = {}
for r in dst.execute("SELECT tagID, name FROM tags"):
dst_tags[r["name"]] = r["tagID"]
src_tag_map: dict[int, int] = {}
for r in src.execute("SELECT tagID, name FROM tags"):
if r["name"] in dst_tags:
src_tag_map[r["tagID"]] = dst_tags[r["name"]]
else:
max_tag_id += 1
dst.execute(
"INSERT INTO tags (tagID, name) VALUES (?,?)", (max_tag_id, r["name"])
)
dst_tags[r["name"]] = max_tag_id
src_tag_map[r["tagID"]] = max_tag_id
print(f"Mapped {len(src_tag_map)} tags")
# ============================================================
# 5. Migrate fulltextWords (deduplicated by word)
# ============================================================
dst_words: dict[str, int] = {}
for r in dst.execute("SELECT wordID, word FROM fulltextWords"):
dst_words[r["word"]] = r["wordID"]
src_word_map: dict[int, int] = {}
for r in src.execute("SELECT wordID, word FROM fulltextWords"):
if r["word"] in dst_words:
src_word_map[r["wordID"]] = dst_words[r["word"]]
else:
max_word_id += 1
dst.execute(
"INSERT INTO fulltextWords (wordID, word) VALUES (?,?)",
(max_word_id, r["word"]),
)
dst_words[r["word"]] = max_word_id
src_word_map[r["wordID"]] = max_word_id
print(f"Mapped {len(src_word_map)} fulltextWords")
# ============================================================
# 6. Migrate items (new keys, new IDs)
# ============================================================
# Process items in order so parents come before children
host_items = src.execute(
"SELECT itemID, itemTypeID, dateAdded, dateModified, clientDateModified, libraryID, key "
"FROM items WHERE libraryID=? ORDER BY itemID",
(LIBRARY_ID,),
).fetchall()
item_id_map: dict[int, int] = {} # host itemID -> dest itemID
item_key_map: dict[str, str] = {} # host key -> dest key (for storage)
for hi in host_items:
max_item_id += 1
new_key = gen_key(existing_keys)
item_id_map[hi["itemID"]] = max_item_id
item_key_map[hi["key"]] = new_key
dst.execute(
"INSERT INTO items (itemID, itemTypeID, dateAdded, dateModified, clientDateModified, libraryID, key, version, synced) "
"VALUES (?,?,?,?,?,?,?,0,0)",
(
max_item_id,
hi["itemTypeID"],
hi["dateAdded"],
hi["dateModified"],
hi["clientDateModified"],
LIBRARY_ID,
new_key,
),
)
print(f"Migrated {len(item_id_map)} items")
# ============================================================
# 7. Migrate itemData
# ============================================================
count = 0
for r in src.execute("SELECT itemID, fieldID, valueID FROM itemData"):
if r["itemID"] not in item_id_map:
continue
dst.execute(
"INSERT INTO itemData (itemID, fieldID, valueID) VALUES (?,?,?)",
(item_id_map[r["itemID"]], r["fieldID"], src_value_map[r["valueID"]]),
)
count += 1
print(f"Migrated {count} itemData rows")
# ============================================================
# 8. Migrate itemCreators
# ============================================================
count = 0
for r in src.execute(
"SELECT itemID, creatorID, creatorTypeID, orderIndex FROM itemCreators"
):
if r["itemID"] not in item_id_map:
continue
dst.execute(
"INSERT INTO itemCreators (itemID, creatorID, creatorTypeID, orderIndex) VALUES (?,?,?,?)",
(
item_id_map[r["itemID"]],
src_creator_map[r["creatorID"]],
r["creatorTypeID"],
r["orderIndex"],
),
)
count += 1
print(f"Migrated {count} itemCreators rows")
# ============================================================
# 9. Migrate itemAttachments
# ============================================================
count = 0
for r in src.execute(
"SELECT itemID, parentItemID, linkMode, contentType, charsetID, path, syncState, storageModTime, storageHash FROM itemAttachments"
):
if r["itemID"] not in item_id_map:
continue
parent = (
item_id_map.get(r["parentItemID"])
if r["parentItemID"] is not None
else None
)
dst.execute(
"INSERT INTO itemAttachments (itemID, parentItemID, linkMode, contentType, charsetID, path, syncState, storageModTime, storageHash) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(
item_id_map[r["itemID"]],
parent,
r["linkMode"],
r["contentType"],
r["charsetID"],
r["path"],
r["syncState"],
r["storageModTime"],
r["storageHash"],
),
)
count += 1
print(f"Migrated {count} itemAttachments rows")
# ============================================================
# 10. Migrate itemNotes
# ============================================================
count = 0
for r in src.execute("SELECT itemID, parentItemID, note, title FROM itemNotes"):
if r["itemID"] not in item_id_map:
continue
parent = (
item_id_map.get(r["parentItemID"])
if r["parentItemID"] is not None
else None
)
dst.execute(
"INSERT INTO itemNotes (itemID, parentItemID, note, title) VALUES (?,?,?,?)",
(item_id_map[r["itemID"]], parent, r["note"], r["title"]),
)
count += 1
print(f"Migrated {count} itemNotes rows")
# ============================================================
# 11. Migrate itemTags
# ============================================================
count = 0
for r in src.execute("SELECT itemID, tagID, type FROM itemTags"):
if r["itemID"] not in item_id_map:
continue
dst.execute(
"INSERT INTO itemTags (itemID, tagID, type) VALUES (?,?,?)",
(item_id_map[r["itemID"]], src_tag_map[r["tagID"]], r["type"]),
)
count += 1
print(f"Migrated {count} itemTags rows")
# ============================================================
# 12. Migrate itemRelations
# ============================================================
# Get relationPredicates from both DBs — they should match (standard set)
count = 0
for r in src.execute("SELECT itemID, predicateID, object FROM itemRelations"):
if r["itemID"] not in item_id_map:
continue
dst.execute(
"INSERT OR IGNORE INTO itemRelations (itemID, predicateID, object) VALUES (?,?,?)",
(item_id_map[r["itemID"]], r["predicateID"], r["object"]),
)
count += 1
print(f"Migrated {count} itemRelations rows")
# ============================================================
# 13. Migrate fulltextItems + fulltextItemWords
# ============================================================
count = 0
for r in src.execute(
"SELECT itemID, indexedPages, totalPages, indexedChars, totalChars FROM fulltextItems"
):
if r["itemID"] not in item_id_map:
continue
dst.execute(
"INSERT INTO fulltextItems (itemID, indexedPages, totalPages, indexedChars, totalChars, version, synced) "
"VALUES (?,?,?,?,?,0,0)",
(
item_id_map[r["itemID"]],
r["indexedPages"],
r["totalPages"],
r["indexedChars"],
r["totalChars"],
),
)
count += 1
print(f"Migrated {count} fulltextItems rows")
count = 0
for r in src.execute("SELECT wordID, itemID FROM fulltextItemWords"):
if r["itemID"] not in item_id_map:
continue
if r["wordID"] not in src_word_map:
continue
dst.execute(
"INSERT OR IGNORE INTO fulltextItemWords (wordID, itemID) VALUES (?,?)",
(src_word_map[r["wordID"]], item_id_map[r["itemID"]]),
)
count += 1
print(f"Migrated {count} fulltextItemWords rows")
# ============================================================
# 14. Migrate collectionItems (only top-level items, not child attachments/notes)
# ============================================================
# Zotero triggers prevent adding child attachments/notes to collections
child_item_ids = set()
for r in src.execute(
"SELECT itemID FROM itemAttachments WHERE parentItemID IS NOT NULL"
):
child_item_ids.add(r["itemID"])
for r in src.execute("SELECT itemID FROM itemNotes WHERE parentItemID IS NOT NULL"):
child_item_ids.add(r["itemID"])
count = 0
for r in src.execute(
"SELECT collectionID, itemID, orderIndex FROM collectionItems"
):
if r["itemID"] not in item_id_map:
continue
if r["collectionID"] not in coll_id_map:
continue
dst.execute(
"INSERT OR IGNORE INTO collectionItems (collectionID, itemID, orderIndex) VALUES (?,?,?)",
(coll_id_map[r["collectionID"]], item_id_map[r["itemID"]], r["orderIndex"]),
)
count += 1
print(f"Migrated {count} collectionItems rows")
# Also add all top-level items to the archive collection itself
top_count = 0
for host_id, dest_id in item_id_map.items():
if host_id not in child_item_ids:
dst.execute(
"INSERT OR IGNORE INTO collectionItems (collectionID, itemID, orderIndex) VALUES (?,?,0)",
(archive_coll_id, dest_id),
)
top_count += 1
print(f"Added {top_count} top-level items to archive collection")
# ============================================================
# 15. Commit
# ============================================================
dst.commit()
print("Database committed successfully")
# ============================================================
# 16. Copy storage directories
# ============================================================
copied = 0
skipped = 0
for old_key, new_key in item_key_map.items():
src_dir = HOST_STORAGE / old_key
dst_dir = CONTAINER_STORAGE / new_key
if src_dir.is_dir():
if dst_dir.exists():
skipped += 1
continue
shutil.copytree(str(src_dir), str(dst_dir))
copied += 1
print(f"Copied {copied} storage directories, skipped {skipped}")
src.close()
dst.close()
print("Migration complete!")
if __name__ == "__main__":
main()