Skip to content

Instantly share code, notes, and snippets.

@DataGreed
Created July 19, 2026 18:16
Show Gist options
  • Select an option

  • Save DataGreed/263f8a5b95f285aa513cb97b25779230 to your computer and use it in GitHub Desktop.

Select an option

Save DataGreed/263f8a5b95f285aa513cb97b25779230 to your computer and use it in GitHub Desktop.
export_ibooks_annotations.py
#!/usr/bin/env python3
"""Export Apple Books (iBooks) highlights and notes to Markdown.
Usage: python3 export_ibooks_annotations.py [output.md]
Run on macOS. No dependencies beyond the standard library.
"""
import datetime
import glob
import os
import re
import sqlite3
import sys
HOME = os.path.expanduser("~")
ANNOT_GLOB = os.path.join(
HOME, "Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation*.sqlite")
LIB_GLOB = os.path.join(
HOME, "Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary*.sqlite")
CORE_DATA_EPOCH = 978307200 # 2001-01-01 in Unix time
COLORS = {0: "underline", 1: "green", 2: "blue", 3: "yellow", 4: "pink", 5: "purple"}
def find_db(pattern, name):
matches = sorted(glob.glob(pattern))
if not matches:
sys.exit(f"Could not find {name} database (looked for {pattern}).\n"
"Open the Books app once so the databases exist, or check the path.")
return matches[-1]
def cfi_sort_key(cfi):
"""Extract numeric steps from an epubcfi string for ordering."""
if not cfi:
return []
return [int(n) for n in re.findall(r"/(\d+)", cfi)]
def chapter_from_cfi(cfi):
"""Fallback chapter label parsed from the epubcfi spine reference."""
if not cfi:
return None
m = re.search(r"\[([^\]]+)\]", cfi) # e.g. epubcfi(/6/24[chapter07]! ...
return m.group(1) if m else None
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "ibooks_annotations.md"
lib_db = find_db(LIB_GLOB, "library")
annot_db = find_db(ANNOT_GLOB, "annotation")
lib = sqlite3.connect(f"file:{lib_db}?mode=ro", uri=True)
books = {row[0]: (row[1] or "Untitled", row[2] or "Unknown author")
for row in lib.execute(
"SELECT ZASSETID, ZTITLE, ZAUTHOR FROM ZBKLIBRARYASSET")}
lib.close()
ann = sqlite3.connect(f"file:{annot_db}?mode=ro", uri=True)
rows = ann.execute("""
SELECT ZANNOTATIONASSETID, ZANNOTATIONSELECTEDTEXT, ZANNOTATIONNOTE,
ZFUTUREPROOFING5, ZANNOTATIONLOCATION, ZANNOTATIONCREATIONDATE,
ZANNOTATIONSTYLE
FROM ZAEANNOTATION
WHERE ZANNOTATIONDELETED = 0
AND (ZANNOTATIONSELECTEDTEXT IS NOT NULL OR ZANNOTATIONNOTE IS NOT NULL)
""").fetchall()
ann.close()
by_book = {}
for asset_id, text, note, chapter, cfi, created, style in rows:
by_book.setdefault(asset_id, []).append(
(cfi_sort_key(cfi), chapter or chapter_from_cfi(cfi), text, note, created, style))
lines = ["# Apple Books annotations", ""]
total = 0
for asset_id, items in by_book.items():
title, author = books.get(asset_id, (f"Unknown book ({asset_id})", ""))
items.sort(key=lambda x: x[0])
lines += [f"## {title} — {author}", ""]
current_chapter = object() # sentinel
for _, chapter, text, note, created, style in items:
if chapter != current_chapter:
current_chapter = chapter
lines += [f"### {chapter or 'Unknown chapter'}", ""]
if text:
for para in text.strip().splitlines():
lines.append(f"> {para}")
if note:
lines.append(f"\n**Note:** {note.strip()}")
meta = []
if style in COLORS:
meta.append(COLORS[style])
if created:
dt = datetime.datetime.fromtimestamp(created + CORE_DATA_EPOCH)
meta.append(dt.strftime("%Y-%m-%d"))
if meta:
lines.append(f"\n*({', '.join(meta)})*")
lines.append("")
total += 1
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"Exported {total} annotations from {len(by_book)} books to {out_path}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment