|
""" |
|
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()) |