Created
July 19, 2026 18:16
-
-
Save DataGreed/263f8a5b95f285aa513cb97b25779230 to your computer and use it in GitHub Desktop.
export_ibooks_annotations.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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