Some checks failed
CI / skinny-install (aco) (push) Successful in 45s
CI / skinny-install (api) (push) Successful in 29s
CI / skinny-install (bcda) (push) Successful in 25s
CI / skinny-install (bib) (push) Successful in 23s
CI / skinny-install (bls) (push) Successful in 20s
CI / skinny-install (ccw) (push) Successful in 36s
CI / skinny-install (cli) (push) Successful in 27s
CI / skinny-install (cms) (push) Successful in 24s
CI / skinny-install (conf) (push) Successful in 27s
CI / skinny-install (pfs) (push) Successful in 25s
CI / skinny-install (rex) (push) Successful in 25s
CI / lint-test (push) Successful in 6m2s
Infra CI / notebooks (push) Successful in 7s
Infra CI / zotero (push) Failing after 6s
Infra CI / docs (push) Successful in 33s
Infra CI / api (push) Successful in 6s
Infra CI / mc (push) Successful in 7s
Deploy / build-scan-report (push) Has been cancelled
- Fix all 72 ruff lint errors (unused imports, unused variables, E402) - Format all 14 unformatted dev/scripts files - Move generated artifacts to assets/ (dag.html, pfs.html) - Remove duplicate root coverage.svg (already in assets/icons/) - Update .dockerignore for infra/ tree layout - Update .gitignore: add .env.bak, mirrors/, htmlcov/ - Fix stale path refs in coverage_badge.py, woodpecker backend, test_network_isolation.sh, docs custom.css - Add .gitkeep to empty dirs (infra/polaris, cloud/*/terraform) - Delete 12 stale local branches, 10 stale remote branches
491 lines
16 KiB
Python
491 lines
16 KiB
Python
"""One-time migration from Zotero SQLite (EAV) to bib SQLite (denormalized).
|
|
|
|
Reads ``zotero.sqlite`` (61-table EAV schema with 4-5 joins per item)
|
|
and writes ``bib.sqlite`` (8-table denormalized schema).
|
|
|
|
Usage::
|
|
|
|
uv run python dev/scripts/migrate_zotero.py \\
|
|
--src zotero/data/zotero.sqlite \\
|
|
--dst data/bib.sqlite
|
|
|
|
Mapping:
|
|
Zotero itemType → bib item_type:
|
|
- statute (code="FR") → rule
|
|
- statute (code="C.F.R.") → regulation
|
|
- report → manual
|
|
- webpage → download
|
|
- document → source
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure project root is on sys.path for bib imports
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
|
|
|
|
from bib.store import Store # noqa: E402
|
|
|
|
|
|
def _read_zotero(src: str) -> sqlite3.Connection:
|
|
"""Open Zotero SQLite read-only."""
|
|
con = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
|
|
con.row_factory = sqlite3.Row
|
|
return con
|
|
|
|
|
|
def _get_item_fields(zcon: sqlite3.Connection, item_id: int) -> dict:
|
|
"""Read all fields for a Zotero item via the EAV join."""
|
|
rows = zcon.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 = ?""",
|
|
(item_id,),
|
|
).fetchall()
|
|
return {r["fieldName"]: r["value"] for r in rows}
|
|
|
|
|
|
def _get_item_tags(zcon: sqlite3.Connection, item_id: int) -> list[str]:
|
|
"""Read tags for a Zotero item."""
|
|
rows = zcon.execute(
|
|
"""SELECT t.name FROM itemTags it
|
|
JOIN tags t ON it.tagID = t.tagID
|
|
WHERE it.itemID = ?""",
|
|
(item_id,),
|
|
).fetchall()
|
|
return [r["name"] for r in rows]
|
|
|
|
|
|
def _get_item_collections(zcon: sqlite3.Connection, item_id: int) -> list[str]:
|
|
"""Read collection keys for a Zotero item."""
|
|
rows = zcon.execute(
|
|
"""SELECT c.key FROM collectionItems ci
|
|
JOIN collections c ON ci.collectionID = c.collectionID
|
|
WHERE ci.itemID = ?""",
|
|
(item_id,),
|
|
).fetchall()
|
|
return [r["key"] for r in rows]
|
|
|
|
|
|
def _get_item_creators(zcon: sqlite3.Connection, item_id: int) -> list[dict]:
|
|
"""Read creators for a Zotero item."""
|
|
rows = zcon.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""",
|
|
(item_id,),
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"first_name": r["firstName"] or "",
|
|
"last_name": r["lastName"] or "",
|
|
"role": r["creatorType"] or "author",
|
|
"sort_order": r["orderIndex"],
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def _classify_item(zotero_type: str, fields: dict) -> str:
|
|
"""Map Zotero itemType + fields to bib item_type."""
|
|
if zotero_type == "statute":
|
|
code = fields.get("code", "")
|
|
if "C.F.R." in code:
|
|
return "regulation"
|
|
return "rule"
|
|
if zotero_type == "report":
|
|
return "manual"
|
|
if zotero_type == "webpage":
|
|
return "download"
|
|
if zotero_type == "document":
|
|
return "source"
|
|
# Fallback
|
|
return "source"
|
|
|
|
|
|
def _build_extra_json(item_type: str, fields: dict) -> str:
|
|
"""Build the extra_json blob from Zotero fields."""
|
|
if item_type == "rule":
|
|
history = fields.get("history", "")
|
|
doc_num = ""
|
|
rule_type = ""
|
|
eff_date = ""
|
|
for part in history.split("; "):
|
|
if part.startswith("Document: "):
|
|
doc_num = part[10:]
|
|
elif part.startswith("Type: "):
|
|
rule_type = part[6:]
|
|
elif part.startswith("Effective: "):
|
|
eff_date = part[11:]
|
|
return json.dumps(
|
|
{
|
|
"fr_volume": fields.get("codeNumber", ""),
|
|
"fr_page": fields.get("pages", ""),
|
|
"document_number": doc_num,
|
|
"cms_id": fields.get("session", ""),
|
|
"rule_type": rule_type,
|
|
"effective_date": eff_date,
|
|
}
|
|
)
|
|
|
|
if item_type == "regulation":
|
|
history = fields.get("history", "")
|
|
part = ""
|
|
authority = ""
|
|
for segment in history.split("; "):
|
|
if segment.startswith("Part "):
|
|
part = segment[5:]
|
|
elif segment.startswith("Authority: "):
|
|
authority = segment[11:]
|
|
return json.dumps(
|
|
{
|
|
"cfr_title": fields.get("codeNumber", ""),
|
|
"cfr_part": part,
|
|
"cfr_section": fields.get("section", ""),
|
|
"authority": authority,
|
|
"effective_date": fields.get("dateEnacted", ""),
|
|
}
|
|
)
|
|
|
|
if item_type == "manual":
|
|
extra = fields.get("extra", "")
|
|
transmittal = ""
|
|
for line in extra.split("\n"):
|
|
if line.startswith("Transmittal: "):
|
|
transmittal = line[13:]
|
|
chapter = fields.get("seriesNumber", "")
|
|
if chapter.startswith("Chapter "):
|
|
chapter = chapter[8:]
|
|
return json.dumps(
|
|
{
|
|
"manual_name": fields.get("seriesTitle", ""),
|
|
"pub_number": fields.get("reportNumber", ""),
|
|
"chapter": chapter,
|
|
"transmittal": transmittal,
|
|
}
|
|
)
|
|
|
|
if item_type == "download":
|
|
extra = fields.get("extra", "")
|
|
file_urls: list[str] = []
|
|
for line in extra.split("\n"):
|
|
if line.startswith("Files: "):
|
|
file_urls = [u.strip() for u in line[7:].split(";") if u.strip()]
|
|
return json.dumps(
|
|
{
|
|
"page_type": "",
|
|
"file_urls": file_urls,
|
|
"year": None,
|
|
"quarter": "",
|
|
"website_title": fields.get("websiteTitle", ""),
|
|
}
|
|
)
|
|
|
|
if item_type == "source":
|
|
return json.dumps(
|
|
{
|
|
"doc_type": fields.get("type", ""),
|
|
}
|
|
)
|
|
|
|
return "{}"
|
|
|
|
|
|
def _get_title(item_type: str, fields: dict) -> str:
|
|
"""Extract the title field (differs by Zotero itemType)."""
|
|
if item_type in ("rule", "regulation"):
|
|
return fields.get("nameOfAct", "")
|
|
return fields.get("title", "")
|
|
|
|
|
|
def _get_date(item_type: str, fields: dict) -> str:
|
|
"""Extract the publication date."""
|
|
if item_type in ("rule", "regulation"):
|
|
return fields.get("dateEnacted", "")
|
|
return fields.get("date", "")
|
|
|
|
|
|
def migrate(src: str, dst: str) -> dict[str, int]:
|
|
"""Run the migration. Returns count summary."""
|
|
zcon = _read_zotero(src)
|
|
store = Store(dst)
|
|
bcon = store._con()
|
|
|
|
counts = {
|
|
"items": 0,
|
|
"tags": 0,
|
|
"collections": 0,
|
|
"attachments": 0,
|
|
"notes": 0,
|
|
"creators": 0,
|
|
"skipped": 0,
|
|
}
|
|
|
|
# ── 1. Collections ───────────────────────────────────────
|
|
# Build a mapping of Zotero collectionID → bib collection id
|
|
zot_collections = zcon.execute(
|
|
"""SELECT collectionID, collectionName, key, parentCollectionID
|
|
FROM collections WHERE libraryID = 1
|
|
ORDER BY parentCollectionID NULLS FIRST, collectionName"""
|
|
).fetchall()
|
|
|
|
zot_col_id_to_key: dict[int, str] = {}
|
|
for zc in zot_collections:
|
|
zot_col_id_to_key[zc["collectionID"]] = zc["key"]
|
|
|
|
for zc in zot_collections:
|
|
parent_id = None
|
|
if zc["parentCollectionID"]:
|
|
parent_key = zot_col_id_to_key.get(zc["parentCollectionID"])
|
|
if parent_key:
|
|
row = bcon.execute(
|
|
"SELECT id FROM collections WHERE key = ?",
|
|
(parent_key,),
|
|
).fetchone()
|
|
if row:
|
|
parent_id = row["id"]
|
|
|
|
bcon.execute(
|
|
"INSERT OR IGNORE INTO collections (key, name, parent_id) VALUES (?, ?, ?)",
|
|
(zc["key"], zc["collectionName"], parent_id),
|
|
)
|
|
counts["collections"] += 1
|
|
|
|
bcon.commit()
|
|
|
|
# ── 2. Content items ─────────────────────────────────────
|
|
content_items = zcon.execute(
|
|
"""SELECT i.itemID, i.key, it.typeName
|
|
FROM items i
|
|
JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
|
|
WHERE i.libraryID = 1
|
|
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
|
|
AND it.typeName NOT IN ('attachment', 'note')
|
|
ORDER BY i.itemID"""
|
|
).fetchall()
|
|
|
|
for zi in content_items:
|
|
fields = _get_item_fields(zcon, zi["itemID"])
|
|
zot_type = zi["typeName"]
|
|
|
|
item_type = _classify_item(zot_type, fields)
|
|
title = _get_title(item_type, fields)
|
|
date_pub = _get_date(item_type, fields)
|
|
url = fields.get("url", "")
|
|
access_date = fields.get("accessDate", "")
|
|
abstract = fields.get("abstractNote", "")
|
|
institution = fields.get("institution", "") or fields.get("publisher", "")
|
|
extra = fields.get("extra", "")
|
|
extra_json = _build_extra_json(item_type, fields)
|
|
|
|
bcon.execute(
|
|
"""INSERT OR IGNORE INTO items
|
|
(key, item_type, title, url, date_published,
|
|
access_date, abstract, institution, extra, extra_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
zi["key"],
|
|
item_type,
|
|
title,
|
|
url,
|
|
date_pub,
|
|
access_date,
|
|
abstract,
|
|
institution,
|
|
extra,
|
|
extra_json,
|
|
),
|
|
)
|
|
|
|
bib_item = bcon.execute(
|
|
"SELECT id FROM items WHERE key = ?", (zi["key"],)
|
|
).fetchone()
|
|
if bib_item is None:
|
|
counts["skipped"] += 1
|
|
continue
|
|
|
|
bib_id = bib_item["id"]
|
|
counts["items"] += 1
|
|
|
|
# Tags
|
|
ztags = _get_item_tags(zcon, zi["itemID"])
|
|
for tag_name in ztags:
|
|
tag_id = store._ensure_tag(tag_name)
|
|
bcon.execute(
|
|
"INSERT OR IGNORE INTO item_tags (item_id, tag_id) VALUES (?, ?)",
|
|
(bib_id, tag_id),
|
|
)
|
|
counts["tags"] += 1
|
|
|
|
# Collections
|
|
zcols = _get_item_collections(zcon, zi["itemID"])
|
|
for ckey in zcols:
|
|
crow = bcon.execute(
|
|
"SELECT id FROM collections WHERE key = ?", (ckey,)
|
|
).fetchone()
|
|
if crow:
|
|
bcon.execute(
|
|
"INSERT OR IGNORE INTO collection_items "
|
|
"(collection_id, item_id) VALUES (?, ?)",
|
|
(crow["id"], bib_id),
|
|
)
|
|
|
|
# Creators
|
|
creators = _get_item_creators(zcon, zi["itemID"])
|
|
for cr in creators:
|
|
# Find or create creator
|
|
crow = bcon.execute(
|
|
"SELECT id FROM creators WHERE first_name = ? AND last_name = ?",
|
|
(cr["first_name"], cr["last_name"]),
|
|
).fetchone()
|
|
if crow:
|
|
creator_id = crow["id"]
|
|
else:
|
|
cur = bcon.execute(
|
|
"INSERT INTO creators (first_name, last_name) VALUES (?, ?)",
|
|
(cr["first_name"], cr["last_name"]),
|
|
)
|
|
creator_id = cur.lastrowid
|
|
counts["creators"] += 1
|
|
|
|
bcon.execute(
|
|
"INSERT OR IGNORE INTO item_creators "
|
|
"(item_id, creator_id, role, sort_order) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(bib_id, creator_id, cr["role"], cr["sort_order"]),
|
|
)
|
|
|
|
bcon.commit()
|
|
|
|
# ── 3. Attachments ───────────────────────────────────────
|
|
zot_attachments = zcon.execute(
|
|
"""SELECT ia.itemID, ia.parentItemID, ia.path,
|
|
ia.contentType, i.key
|
|
FROM itemAttachments ia
|
|
JOIN items i ON ia.itemID = i.itemID
|
|
WHERE i.libraryID = 1
|
|
AND ia.parentItemID IS NOT NULL
|
|
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)"""
|
|
).fetchall()
|
|
|
|
for za in zot_attachments:
|
|
# Find parent in bib
|
|
parent_row = zcon.execute(
|
|
"SELECT key FROM items WHERE itemID = ?",
|
|
(za["parentItemID"],),
|
|
).fetchone()
|
|
if parent_row is None:
|
|
continue
|
|
|
|
bib_parent = bcon.execute(
|
|
"SELECT id FROM items WHERE key = ?", (parent_row["key"],)
|
|
).fetchone()
|
|
if bib_parent is None:
|
|
continue
|
|
|
|
# Extract filename from Zotero storage path
|
|
zpath = za["path"] or ""
|
|
filename = zpath.replace("storage:", "") if zpath else za["key"]
|
|
|
|
bcon.execute(
|
|
"""INSERT OR IGNORE INTO attachments
|
|
(item_id, key, filename, content_type, storage_path)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(
|
|
bib_parent["id"],
|
|
za["key"],
|
|
filename,
|
|
za["contentType"] or "",
|
|
zpath,
|
|
),
|
|
)
|
|
counts["attachments"] += 1
|
|
|
|
bcon.commit()
|
|
|
|
# ── 4. Notes ─────────────────────────────────────────────
|
|
zot_notes = zcon.execute(
|
|
"""SELECT inote.itemID, inote.parentItemID, inote.note,
|
|
inote.title, i.key
|
|
FROM itemNotes inote
|
|
JOIN items i ON inote.itemID = i.itemID
|
|
WHERE i.libraryID = 1
|
|
AND inote.parentItemID IS NOT NULL
|
|
AND i.itemID NOT IN (SELECT itemID FROM deletedItems)"""
|
|
).fetchall()
|
|
|
|
for zn in zot_notes:
|
|
parent_row = zcon.execute(
|
|
"SELECT key FROM items WHERE itemID = ?",
|
|
(zn["parentItemID"],),
|
|
).fetchone()
|
|
if parent_row is None:
|
|
continue
|
|
|
|
bib_parent = bcon.execute(
|
|
"SELECT id FROM items WHERE key = ?", (parent_row["key"],)
|
|
).fetchone()
|
|
if bib_parent is None:
|
|
continue
|
|
|
|
bcon.execute(
|
|
"INSERT INTO notes (item_id, title, content) VALUES (?, ?, ?)",
|
|
(bib_parent["id"], zn["title"] or "", zn["note"] or ""),
|
|
)
|
|
counts["notes"] += 1
|
|
|
|
bcon.commit()
|
|
|
|
# ── 5. Metadata ──────────────────────────────────────────
|
|
bcon.execute(
|
|
"INSERT OR REPLACE INTO bib_meta (key, value) VALUES (?, ?)",
|
|
("migrated_from", src),
|
|
)
|
|
bcon.commit()
|
|
|
|
zcon.close()
|
|
store.close()
|
|
return counts
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Migrate Zotero SQLite to bib SQLite")
|
|
from conf import path as _conf_path
|
|
|
|
parser.add_argument(
|
|
"--src",
|
|
default=str(_conf_path("db.zotero")),
|
|
help="Path to Zotero SQLite database",
|
|
)
|
|
parser.add_argument(
|
|
"--dst",
|
|
default=str(_conf_path("db.bib")),
|
|
help="Path to output bib SQLite database",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Ensure destination directory exists
|
|
Path(args.dst).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"Migrating: {args.src} → {args.dst}")
|
|
counts = migrate(args.src, args.dst)
|
|
|
|
print("Migration complete:")
|
|
for k, v in counts.items():
|
|
print(f" {k}: {v}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|