export.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. #!/usr/bin/env python3
  2. import mimetypes
  3. import os
  4. import pathlib
  5. from typing import List
  6. import click
  7. import frontmatter
  8. import gkeepapi
  9. from mdutils.mdutils import MdUtils
  10. from pathvalidate import sanitize_filename
  11. def login(user_email: str, password: str) -> gkeepapi.Keep:
  12. keep = gkeepapi.Keep()
  13. try:
  14. keep.login(user_email, password)
  15. except gkeepapi.exception.LoginException as ex:
  16. raise click.BadParameter(f"Login failed: {str(ex)}")
  17. return keep
  18. def download_images(
  19. keep: gkeepapi.Keep,
  20. note_count: int,
  21. note: gkeepapi._node.Note,
  22. outpath: pathlib.Path,
  23. ) -> List[pathlib.Path]:
  24. if not note.images and not note.drawings:
  25. return []
  26. ret = []
  27. for image in note.images + note.drawings:
  28. meta = image.blob.save()
  29. # ocr = meta["extracted_text"] # TODO save ocr as metadata? in markdown or image?
  30. url = keep._media_api.get(image)
  31. print("Downloading %s" % url)
  32. if meta.get("type", "") == "DRAWING":
  33. extension = mimetypes.guess_extension(
  34. meta.get("drawingInfo", {})
  35. .get("snapshotData", {})
  36. .get("mimetype", "image/png")
  37. ) # All drawings seem to be pngs
  38. else: # 'IMAGE'
  39. extension = mimetypes.guess_extension(meta.get("mimetype", "image/jpeg"))
  40. imgfile = (
  41. outpath
  42. / f'{sanitize_filename(f"{note_count:04} - " + image.server_id,max_len=135)}{extension}'
  43. )
  44. imgresp = keep._media_api._session.get(url)
  45. with imgfile.open("wb") as f:
  46. f.write(imgresp.content)
  47. ret.append(imgfile)
  48. return ret
  49. def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
  50. metadata = {
  51. "id": note.id,
  52. "title": note.title,
  53. "pinned": note.pinned,
  54. "trashed": note.trashed,
  55. "deleted": note.deleted,
  56. "color": note.color.name,
  57. "type": note.type.name,
  58. "parent_id": note.parent_id,
  59. "sort": note.sort,
  60. "url": note.url,
  61. "timestamps": {
  62. "created": note.timestamps.created.timestamp(),
  63. "edited": note.timestamps.edited.timestamp(),
  64. "updated": note.timestamps.updated.timestamp(),
  65. },
  66. }
  67. # 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.
  68. if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
  69. metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
  70. if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
  71. metadata["timestamps"]["deleted"] = note.timestamps.deleted.timestamp()
  72. return frontmatter.Post(markdown, handler=None, **metadata)
  73. def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
  74. doc = MdUtils(
  75. ""
  76. ) # mdutils requires a string file name. Since we're not using it to write files, we can ignore that.
  77. doc.new_header(1, note.title)
  78. doc.new_header(2, "Note")
  79. text = note.text
  80. text = text.replace("☑ ", "- [X] ")
  81. text = text.replace("☐ ", "- [ ] ")
  82. doc.new_paragraph(text)
  83. if note.annotations.links:
  84. doc.new_line()
  85. doc.new_line()
  86. doc.new_header(2, "Links")
  87. doc.new_list(
  88. [
  89. doc.new_inline_link(link=link.url, text=link.title)
  90. for link in note.annotations.links
  91. ]
  92. )
  93. if images:
  94. doc.new_line()
  95. doc.new_header(2, "Attached Images")
  96. for image in images:
  97. doc.new_line(doc.new_inline_image("", image.name))
  98. return doc.file_data_text
  99. @click.command()
  100. @click.option(
  101. "--directory",
  102. "-d",
  103. default="./gkeep-export",
  104. help="Output directory for exported notes",
  105. )
  106. @click.option(
  107. "--user",
  108. "-u",
  109. default=lambda: os.environ.get("GKEEP_USER"),
  110. prompt=True,
  111. help="Google account email (environment variable 'GKEEP_USER')",
  112. )
  113. @click.option(
  114. "--password",
  115. "-p",
  116. default=lambda: os.environ.get("GKEEP_PASSWORD"),
  117. prompt=True,
  118. help="Google account password (environment variable 'GKEEP_PASSWORD')",
  119. hide_input=True,
  120. )
  121. @click.option(
  122. "--header/--no-header",
  123. default=True,
  124. help="Choose to include or exclude the frontmatter header (Default: on)",
  125. )
  126. def main(directory, user, password, header):
  127. """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
  128. outpath = pathlib.Path(directory).resolve()
  129. click.echo(f"Output directory: {outpath}")
  130. click.echo("Logging in.")
  131. keep = login(user, password)
  132. click.echo("Beginning note export.")
  133. if not outpath.exists():
  134. click.echo("output directory does not exist, creating.")
  135. outpath.mkdir(parents=True)
  136. notes: List[gkeepapi.node.TopLevelNode] = keep.all()
  137. note_count = 0
  138. for note in notes: # type: gkeepapi.node.TopLevelNode
  139. note_count += 1
  140. click.echo(f"Processing note #{note_count}")
  141. title = note.title.strip()
  142. if not len(title):
  143. title = "untitled"
  144. outfile = (
  145. outpath
  146. / f'{sanitize_filename(f"{note_count:04} - " + title,max_len=135)}.md'
  147. )
  148. images = download_images(keep, note_count, note, outpath)
  149. markdown = build_markdown(note, images)
  150. with outfile.open("wb+") as f:
  151. if header:
  152. fmatter = build_frontmatter(note, markdown)
  153. frontmatter.dump(fmatter, f)
  154. else:
  155. f.write(markdown.encode("utf-8"))
  156. click.echo("Done.")
  157. if __name__ == "__main__":
  158. main()