| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- #!/usr/bin/env python3
- import mimetypes
- import pathlib
- from typing import List
- import click
- import frontmatter
- import gkeepapi
- from mdutils.mdutils import MdUtils
- from pathvalidate import sanitize_filename
- mimetypes.add_type("audio/3gpp", ".3gp")
- def login(user_email: str, password: str) -> gkeepapi.Keep:
- keep = gkeepapi.Keep()
- try:
- keep.login(user_email, password)
- except gkeepapi.exception.LoginException as ex:
- raise click.BadParameter(f"Login failed: {str(ex)}")
- return keep
- def download_images(
- keep: gkeepapi.Keep,
- note_count: int,
- note: gkeepapi._node.Note,
- outpath: pathlib.Path,
- ) -> List[pathlib.Path]:
- if not note.images and not note.drawings and not note.audio:
- return []
- ret = []
- for media in note.images + note.drawings + note.audio:
- meta = media.blob.save()
- # ocr = meta["extracted_text"] # TODO save ocr as metadata? in markdown or image?
- url = keep._media_api.get(media)
- print("Downloading %s" % url)
- if meta.get("type", "") == "DRAWING":
- extension = mimetypes.guess_extension(
- meta.get("drawingInfo", {})
- .get("snapshotData", {})
- .get("mimetype", "image/png")
- ) # All drawings seem to be pngs
- elif meta.get("type") == "IMAGE":
- extension = mimetypes.guess_extension(meta.get("mimetype", "image/jpeg"))
- else: # 'AUDIO'
- extension = mimetypes.guess_extension(meta.get("mimetype", "audio/3gpp"))
- media_file = (
- outpath
- / f'{sanitize_filename(f"{note_count:04} - " + media.server_id,max_len=135)}{extension}'
- )
- media_data = keep._media_api._session.get(url)
- with media_file.open("wb") as f:
- f.write(media_data.content)
- ret.append(media_file)
- return ret
- def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
- metadata = {
- "id": note.id,
- "title": note.title,
- "pinned": note.pinned,
- "trashed": note.trashed,
- "deleted": note.deleted,
- "color": note.color.name,
- "type": note.type.name,
- "parent_id": note.parent_id,
- "sort": note.sort,
- "url": note.url,
- "timestamps": {
- "created": note.timestamps.created.timestamp(),
- "edited": note.timestamps.edited.timestamp(),
- "updated": note.timestamps.updated.timestamp(),
- },
- }
- # gkeepapi appears to be treating "0" as a timestamp rather than null. Sometimes the data structure does not have the key at all instead of 0.
- if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
- metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
- if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
- metadata["timestamps"]["deleted"] = note.timestamps.deleted.timestamp()
- return frontmatter.Post(markdown, handler=None, **metadata)
- def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
- doc = MdUtils(
- ""
- ) # mdutils requires a string file name. Since we're not using it to write files, we can ignore that.
- doc.new_header(1, note.title)
- doc.new_header(2, "Note")
- text = note.text
- text = text.replace("☑ ", "- [X] ")
- text = text.replace("☐ ", "- [ ] ")
- doc.new_paragraph(text)
- if note.annotations.links:
- doc.new_line()
- doc.new_line()
- doc.new_header(2, "Links")
- doc.new_list(
- [
- doc.new_inline_link(link=link.url, text=link.title)
- for link in note.annotations.links
- ]
- )
- if images:
- doc.new_line()
- doc.new_header(2, "Attached Media")
- for image in images:
- doc.new_line(doc.new_inline_image("", image.name))
- return doc.file_data_text
- @click.command(
- context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
- )
- @click.option(
- "--user",
- "-u",
- prompt=True,
- required=True,
- envvar="GKEEP_USER",
- show_envvar=True,
- help="Google account email (prompt if empty)",
- )
- @click.option(
- "--password",
- "-p",
- prompt=True,
- required=True,
- envvar="GKEEP_PASSWORD",
- show_envvar=True,
- help="Google account password (prompt if empty)",
- hide_input=True,
- )
- @click.option(
- "--directory",
- "-d",
- default="./gkeep-export",
- show_default=True,
- help="Output directory for exported notes",
- type=click.Path(file_okay=False, dir_okay=True, writable=True),
- )
- @click.option(
- "--header/--no-header",
- default=True,
- show_default=True,
- help="Choose to include or exclude the frontmatter header",
- )
- def main(directory, user, password, header):
- """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
- outpath = pathlib.Path(directory).resolve()
- click.echo(f"Output directory: {outpath}")
- click.echo("Logging in.")
- keep = login(user, password)
- click.echo("Beginning note export.")
- if not outpath.exists():
- click.echo("output directory does not exist, creating.")
- outpath.mkdir(parents=True)
- notes: List[gkeepapi.node.TopLevelNode] = keep.all()
- note_count = 0
- for note in notes: # type: gkeepapi.node.TopLevelNode
- note_count += 1
- click.echo(f"Processing note #{note_count}")
- title = note.title.strip()
- if not len(title):
- title = "untitled"
- outfile = (
- outpath
- / f'{sanitize_filename(f"{note_count:04} - " + title,max_len=135)}.md'
- )
- images = download_images(keep, note_count, note, outpath)
- markdown = build_markdown(note, images)
- with outfile.open("wb+") as f:
- if header:
- fmatter = build_frontmatter(note, markdown)
- frontmatter.dump(fmatter, f)
- else:
- f.write(markdown.encode("utf-8"))
- click.echo("Done.")
- if __name__ == "__main__":
- main()
|