Skip to content

Instantly share code, notes, and snippets.

@ongyx
Last active August 16, 2024 15:50
Show Gist options
  • Save ongyx/636b43419865fc19594fd43fe9ca29e9 to your computer and use it in GitHub Desktop.
Save ongyx/636b43419865fc19594fd43fe9ca29e9 to your computer and use it in GitHub Desktop.
Nimbus - iCloud Notes to Obsidian exporter

Nimbus

A short Python script to convert and export iCloud Notes data to Obsidian.

Why?

Even though Obsidian has a native importer for Apple Notes, it doesn't work if you don't have a Mac device. The only workaround is to request a copy of your iCloud Notes and then unzip them, but this is also tedious to do.

Therefore, I wrote this script to:

  • correctly extract the notes to their original folders,
  • convert their metadata into Obsidian properties,
  • and clean up their filenames to ensure that they can be extracted on Windows.

Usage

Request a copy of your iCloud Notes using the link above, then run the following:

# Arrow is needed for parsing/formatting datetime strings.
pip install arrow

python nimbus.py (path to iCloud Notes.zip) (path to dest folder)

License

Nimbus is licensed under CC0.

"""
Nimbus: iCloud Notes to Obsidian exporter.
This script was written by ongyx (https://github.com/ongyx) and is licensed under CC0 (https://creativecommons.org/publicdomain/zero/1.0/).
"""
import csv
import dataclasses
import io
import pathlib
import re
import shutil
import sys
import textwrap
import zipfile
from collections.abc import Iterator
from typing import TextIO, Self
import arrow
ENCODING = "utf-8"
@dataclasses.dataclass
class Note:
ARROW_DATE_FORMAT = "MMMM D,YYYY H:m A ZZZ"
OBSIDIAN_PROPERTIES = textwrap.dedent(
"""
---
tags:
- {folder}
created_on: {created_on}
modified_on: {modified_on}
pinned: "{pinned}"
deleted: "{deleted}"
drawing_or_handwriting: "{drawing_or_handwriting}"
---
"""
).lstrip()
title: str
created_on: arrow.Arrow
modified_on: arrow.Arrow
pinned: bool
deleted: bool
drawing_or_handwriting: bool
content_hash_at_import: str
@classmethod
def parse(cls, row: dict[str, str]) -> Self:
title = row["title"]
created_on = cls._parse_arrow(row["createdOn"])
modified_on = cls._parse_arrow(row["modifiedOn"])
pinned = cls._parse_bool(row["pinned"])
deleted = cls._parse_bool(row["deleted"])
drawing_or_handwriting = cls._parse_bool(row["drawing/handwriting"])
content_hash_at_import = row["contentHashAtImport"]
return cls(
title,
created_on,
modified_on,
pinned,
deleted,
drawing_or_handwriting,
content_hash_at_import,
)
def to_obsidian_properties(self, folder: str) -> str:
return Note.OBSIDIAN_PROPERTIES.format(
folder=folder,
created_on=self._unparse_arrow(self.created_on),
modified_on=self._unparse_arrow(self.modified_on),
pinned=self._unparse_bool(self.pinned),
deleted=self._unparse_bool(self.deleted),
drawing_or_handwriting=self._unparse_bool(self.drawing_or_handwriting),
)
@staticmethod
def _unparse_bool(yn: bool) -> str:
return "yes" if yn else "no"
@staticmethod
def _unparse_arrow(dt: arrow.Arrow) -> str:
return dt.to("local").isoformat()
@staticmethod
def _parse_bool(yn: str) -> bool:
return yn == "yes"
@staticmethod
def _parse_arrow(dt: str) -> arrow.Arrow:
return arrow.get(dt, Note.ARROW_DATE_FORMAT)
class NoteDetails:
FILE = "iCloud Notes/Notes/Notes_Details.csv"
notes: dict[str, Note]
def __init__(self, zf: zipfile.ZipFile) -> None:
with zf.open(NoteDetails.FILE) as f:
f = io.TextIOWrapper(f, encoding=ENCODING)
# Parse all notes details in the file.
self.notes = {(n := Note.parse(d)).title: n for d in csv.DictReader(f)}
def lookup(self, zi: zipfile.ZipInfo) -> Note | None:
# Undocumented, but allows access to the original filename as recorded in the ZIP file.
path = pathlib.PurePosixPath(zi.orig_filename)
if path.suffix == ".txt":
# Each note is stored in a parent folder named after itself.
# i.e., MyNote -> MyNote/MyNote-(ctime).txt
return self.notes.get(path.parent.name)
return None
class NoteExtractor:
# https://stackoverflow.com/a/31976060
RE_INVALID = re.compile(r"""[<>:"/\\|?*]+""")
zf: zipfile.ZipFile
details: NoteDetails
def __init__(self, zf: zipfile.ZipFile) -> None:
self.zf = zf
self.details = NoteDetails(zf)
def extract(
self, zi: zipfile.ZipInfo, dest: pathlib.Path
) -> pathlib.Path | None:
if note := self.details.lookup(zi):
note_folder = self._parse_folder(zi)
note_filename = NoteExtractor._clean_filename(note.title)
note_dest = dest / note_folder / f"{note_filename}.md"
# Ensure the note's folder exists before opening for writing.
note_dest.parent.mkdir(exist_ok=True)
# Generate Obsidian properties for the note.
note_properties = note.to_obsidian_properties(str(note_folder))
# Copy the note out of the ZIP file.
with (
self.zf.open(zi) as src,
note_dest.open("w", encoding=ENCODING) as dst,
):
src = io.TextIOWrapper(src, encoding=ENCODING)
NoteExtractor._copy_note(src, dst, note, note_properties)
return note_dest
return None
def extract_all(self, dest: pathlib.Path) -> Iterator[pathlib.Path]:
for zi in self.zf.infolist():
if (path := self.extract(zi, dest)):
yield path
def __len__(self) -> int:
return len(self.zf.infolist())
@staticmethod
def _copy_note(src: TextIO, dst: TextIO, note: Note, properties: str):
# Skip the first line if it contains only the note title.
first_line = next(src).rstrip()
if first_line != note.title:
src.seek(0)
# Prepend the Obsidian properties.
dst.write(properties)
# Copy the note's contents.
shutil.copyfileobj(src, dst)
@staticmethod
def _clean_filename(name: str) -> str:
# Replace all invalid filename characters with an underscore.
return NoteExtractor.RE_INVALID.sub("_", name)
@staticmethod
def _parse_folder(zi: zipfile.ZipInfo) -> pathlib.PurePosixPath:
path = pathlib.PurePosixPath(zi.orig_filename)
# Remove the leading 'iCloud Notes' folder and the filename.
# i.e., iCloud Notes/Notes/MyNote/MyNote-{ctime}.txt -> MyNote.txt
parts = path.parts[1:-2]
return pathlib.PurePosixPath(*parts)
def main() -> int:
if len(sys.argv) < 3:
print(f"usage: {__file__} (path to zip) (path to dest)")
return 1
zip_path = pathlib.Path(sys.argv[1]).expanduser().resolve()
dest_path = pathlib.Path(sys.argv[2]).expanduser().resolve()
# Make sure the destination folder exists.
dest_path.mkdir(exist_ok=True)
print(f"[-] Extraction commencing... ")
with zipfile.ZipFile(zip_path) as zf:
extractor = NoteExtractor(zf)
total = len(extractor)
for count, path in enumerate(extractor.extract_all(dest_path), start=1):
print(f"[-] ({count}/{total}) Extracted {path}")
print(f"[+] Extraction complete!")
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment