export.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. #!/usr/bin/env python3
  2. import mimetypes
  3. import pathlib
  4. from typing import List
  5. import click
  6. import frontmatter
  7. import gkeepapi
  8. from mdutils.mdutils import MdUtils
  9. from pathvalidate import sanitize_filename
  10. mimetypes.add_type("audio/3gpp", ".3gp")
  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 and not note.audio:
  25. return []
  26. ret = []
  27. for media in note.images + note.drawings + note.audio:
  28. meta = media.blob.save()
  29. # ocr = meta["extracted_text"] # TODO save ocr as metadata? in markdown or image?
  30. url = keep._media_api.get(media)
  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. elif meta.get("type") == "IMAGE":
  39. extension = mimetypes.guess_extension(meta.get("mimetype", "image/jpeg"))
  40. else: # 'AUDIO'
  41. extension = mimetypes.guess_extension(meta.get("mimetype", "audio/3gpp"))
  42. media_file = (
  43. outpath
  44. / f'{sanitize_filename(f"{note_count:04} - " + media.server_id,max_len=135)}{extension}'
  45. )
  46. media_data = keep._media_api._session.get(url)
  47. with media_file.open("wb") as f:
  48. f.write(media_data.content)
  49. ret.append(media_file)
  50. return ret
  51. def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
  52. metadata = {
  53. "id": note.id,
  54. "title": note.title,
  55. "pinned": note.pinned,
  56. "trashed": note.trashed,
  57. "deleted": note.deleted,
  58. "color": note.color.name,
  59. "type": note.type.name,
  60. "parent_id": note.parent_id,
  61. "sort": note.sort,
  62. "url": note.url,
  63. "timestamps": {
  64. "created": note.timestamps.created.timestamp(),
  65. "edited": note.timestamps.edited.timestamp(),
  66. "updated": note.timestamps.updated.timestamp(),
  67. },
  68. }
  69. # 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.
  70. if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
  71. metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
  72. if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
  73. metadata["timestamps"]["deleted"] = note.timestamps.deleted.timestamp()
  74. return frontmatter.Post(markdown, handler=None, **metadata)
  75. def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
  76. doc = MdUtils(
  77. ""
  78. ) # mdutils requires a string file name. Since we're not using it to write files, we can ignore that.
  79. doc.new_header(1, note.title)
  80. doc.new_header(2, "Note")
  81. text = note.text
  82. text = text.replace("☑ ", "- [X] ")
  83. text = text.replace("☐ ", "- [ ] ")
  84. doc.new_paragraph(text)
  85. if note.annotations.links:
  86. doc.new_line()
  87. doc.new_line()
  88. doc.new_header(2, "Links")
  89. doc.new_list(
  90. [
  91. doc.new_inline_link(link=link.url, text=link.title)
  92. for link in note.annotations.links
  93. ]
  94. )
  95. if images:
  96. doc.new_line()
  97. doc.new_header(2, "Attached Media")
  98. for image in images:
  99. doc.new_line(doc.new_inline_image("", image.name))
  100. return doc.file_data_text
  101. @click.command(
  102. context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
  103. )
  104. @click.option(
  105. "--user",
  106. "-u",
  107. prompt=True,
  108. required=True,
  109. envvar="GKEEP_USER",
  110. show_envvar=True,
  111. help="Google account email (prompt if empty)",
  112. )
  113. @click.option(
  114. "--password",
  115. "-p",
  116. prompt=True,
  117. required=True,
  118. envvar="GKEEP_PASSWORD",
  119. show_envvar=True,
  120. help="Google account password (prompt if empty)",
  121. hide_input=True,
  122. )
  123. @click.option(
  124. "--directory",
  125. "-d",
  126. default="./gkeep-export",
  127. show_default=True,
  128. help="Output directory for exported notes",
  129. type=click.Path(file_okay=False, dir_okay=True, writable=True),
  130. )
  131. @click.option(
  132. "--header/--no-header",
  133. default=True,
  134. show_default=True,
  135. help="Choose to include or exclude the frontmatter header",
  136. )
  137. def main(directory, user, password, header):
  138. """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
  139. outpath = pathlib.Path(directory).resolve()
  140. click.echo(f"Output directory: {outpath}")
  141. click.echo("Logging in.")
  142. keep = login(user, password)
  143. click.echo("Beginning note export.")
  144. if not outpath.exists():
  145. click.echo("output directory does not exist, creating.")
  146. outpath.mkdir(parents=True)
  147. notes: List[gkeepapi.node.TopLevelNode] = keep.all()
  148. note_count = 0
  149. for note in notes: # type: gkeepapi.node.TopLevelNode
  150. note_count += 1
  151. click.echo(f"Processing note #{note_count}")
  152. title = note.title.strip()
  153. if not len(title):
  154. title = "untitled"
  155. outfile = (
  156. outpath
  157. / f'{sanitize_filename(f"{note_count:04} - " + title,max_len=135)}.md'
  158. )
  159. images = download_images(keep, note_count, note, outpath)
  160. markdown = build_markdown(note, images)
  161. with outfile.open("wb+") as f:
  162. if header:
  163. fmatter = build_frontmatter(note, markdown)
  164. frontmatter.dump(fmatter, f)
  165. else:
  166. f.write(markdown.encode("utf-8"))
  167. click.echo("Done.")
  168. if __name__ == "__main__":
  169. main()