export.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. #!/usr/bin/env python3
  2. import datetime
  3. import mimetypes
  4. import os
  5. import pathlib
  6. from typing import Dict, List, NamedTuple, Optional
  7. import click
  8. import frontmatter
  9. import gkeepapi
  10. from mdutils.mdutils import MdUtils
  11. from pathvalidate import sanitize_filename
  12. mimetypes.add_type("audio/3gpp", ".3gp")
  13. def login(user_email: str, password: str) -> gkeepapi.Keep:
  14. keep = gkeepapi.Keep()
  15. try:
  16. keep.login(user_email, password)
  17. except gkeepapi.exception.LoginException as ex:
  18. raise click.BadParameter(f"Login failed: {str(ex)}")
  19. return keep
  20. def download_images(
  21. keep: gkeepapi.Keep,
  22. note: gkeepapi._node.Note,
  23. mediapath: pathlib.Path,
  24. skip_existing: bool,
  25. ) -> List[pathlib.Path]:
  26. if not note.images and not note.drawings and not note.audio:
  27. return []
  28. ret = []
  29. for media in note.images + note.drawings + note.audio:
  30. meta = media.blob.save()
  31. # ocr = meta["extracted_text"] # TODO save ocr as metadata? in markdown or image?
  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. # .jpe just feels weird, but it's my default in testing
  41. if extension == ".jpe":
  42. extension = ".jpg"
  43. else: # 'AUDIO'
  44. extension = mimetypes.guess_extension(meta.get("mimetype", "audio/3gpp"))
  45. # nest media files under folders named by the note's ID
  46. # this simplifies figuring out the note media files came from
  47. note_media_path = mediapath / note.id
  48. note_media_path.mkdir(exist_ok=True)
  49. media_file = (
  50. note_media_path
  51. / f"{sanitize_filename(media.server_id,max_len=135)}{extension}"
  52. )
  53. # checking size isn't perfect, and drawings don't have a size,
  54. # but it doesn't seem right to always re-download images that likely
  55. # haven't changed
  56. if (
  57. skip_existing
  58. and media_file.exists()
  59. and hasattr(media.blob, "byte_size")
  60. and media_file.stat().st_size == media.blob.byte_size
  61. ):
  62. click.echo(
  63. f"Media file f{media_file} exists and is same size as in Google Keep. Skipping."
  64. )
  65. else:
  66. print(
  67. f"Downloading media {meta.get('type')} {media.server_id} for note {note.id}"
  68. )
  69. url = keep._media_api.get(media)
  70. media_data = keep._media_api._session.get(url)
  71. with media_file.open("wb") as f:
  72. f.write(media_data.content)
  73. ret.append(media_file)
  74. return ret
  75. def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
  76. metadata = {
  77. "google_keep_id": note.id,
  78. "title": note.title,
  79. "pinned": note.pinned,
  80. "trashed": note.trashed,
  81. "deleted": note.deleted,
  82. "color": note.color.name,
  83. "type": note.type.name,
  84. "parent_id": note.parent_id,
  85. "sort": note.sort,
  86. "url": note.url,
  87. "tags": [label.name for label in note.labels.all()],
  88. "timestamps": {
  89. "created": note.timestamps.created.timestamp(),
  90. "edited": note.timestamps.edited.timestamp(),
  91. "updated": note.timestamps.updated.timestamp(),
  92. },
  93. }
  94. # 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.
  95. if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
  96. metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
  97. if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
  98. metadata["timestamps"]["deleted"] = note.timestamps.deleted.timestamp()
  99. return frontmatter.Post(markdown, handler=None, **metadata)
  100. def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
  101. doc = MdUtils(
  102. ""
  103. ) # mdutils requires a string file name. Since we're not using it to write files, we can ignore that.
  104. doc.new_header(1, note.title)
  105. doc.new_header(2, "Note")
  106. text = note.text
  107. text = text.replace("☑ ", "- [X] ")
  108. text = text.replace("☐ ", "- [ ] ")
  109. doc.new_paragraph(text)
  110. if note.annotations.links:
  111. doc.new_line()
  112. doc.new_line()
  113. doc.new_header(2, "Links")
  114. doc.new_list(
  115. [
  116. doc.new_inline_link(link=link.url, text=link.title)
  117. for link in note.annotations.links
  118. ]
  119. )
  120. if images:
  121. doc.new_line()
  122. doc.new_header(2, "Attached Media")
  123. for image in images:
  124. doc.new_line(doc.new_inline_image("", image.name))
  125. return doc.file_data_text
  126. MarkdownFile = NamedTuple(
  127. "MarkdownFile",
  128. [
  129. ("path", pathlib.Path),
  130. ("timestamp_updated", Optional[datetime.datetime]),
  131. ("google_keep_id", Optional[str]),
  132. ],
  133. )
  134. MediaFile = NamedTuple(
  135. "MediaFile",
  136. [
  137. ("path", pathlib.Path),
  138. ("parent_id", Optional[str]),
  139. ("google_keep_id", Optional[str]),
  140. ],
  141. )
  142. def index_existing_files(directory: pathlib.Path) -> Dict[str, MarkdownFile]:
  143. index = {}
  144. keep_notes = 0
  145. unknown_notes = 0
  146. errors = 0
  147. media = 0
  148. for file in directory.rglob("*"):
  149. # markdown file
  150. if file.name.endswith(".md"):
  151. try:
  152. with open(file, "r") as f:
  153. fm = frontmatter.load(f)
  154. info = MarkdownFile(
  155. file,
  156. fm.metadata.get("timestamps").get("updated"),
  157. fm.metadata.get("google_keep_id"),
  158. )
  159. if info.google_keep_id:
  160. keep_notes += 1
  161. index[info.google_keep_id] = info
  162. else:
  163. unknown_notes += 1
  164. except IOError as ex:
  165. errors = 0
  166. click.echo(
  167. "Unable to open Markdown file [{os.path.join(root, file)}]. Skipping: {str(ex)}",
  168. err=True,
  169. )
  170. # media file
  171. else:
  172. media += 1
  173. click.echo(f"Ignoring media file [{file}]")
  174. click.echo(
  175. f"Indexed local files: {keep_notes} Google Keep notes, {unknown_notes} unknown markdown files, {media} media files, {errors} errors"
  176. )
  177. return index
  178. def try_rename_note(note: MarkdownFile, target_file: pathlib.Path) -> pathlib.Path:
  179. """
  180. Attempts to rename an existing note to the new canonical filename,
  181. but accepts failures to rename. Returns the path the note now exists
  182. in, either the old path or the new renamed path.
  183. """
  184. click.echo(f"Renaming [{note.path}] to [{target_file}]")
  185. try:
  186. note.path.rename(target_file)
  187. return target_file
  188. except Exception as ex:
  189. click.echo(f"Unable to rename note. Using existing name: %s" % ex, err=True)
  190. return note.path
  191. def build_note_unique_path(
  192. notepath: pathlib.Path,
  193. note: gkeepapi._node.Note,
  194. date_format: str,
  195. local_index: Dict[str, MarkdownFile],
  196. ) -> pathlib.Path:
  197. title = note.title.strip()
  198. if not len(title):
  199. title = "untitled"
  200. date_str = note.timestamps.created.strftime(date_format)
  201. filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.md'
  202. target_path = notepath / filename
  203. if note.id in local_index:
  204. # if the note filename matches the current filename for that note, then we're good
  205. if local_index[note.id].path == target_path:
  206. return local_index[note.id].path
  207. # if re-naming would result in having to de-dupe the target filename, keep the
  208. # exising filename - initial pass at fixing this just resulted in bouncing between
  209. # two different filenames each pass
  210. if target_path.exists():
  211. click.echo(
  212. f"Note {note.id} will not be renamed. Target file [{target_path}] exists."
  213. )
  214. return local_index[note.id].path
  215. # otherwise, if the file already exists avoid overwriting it
  216. # put the unique note ID and an incrementing index at the end of the filename
  217. dedupe_index = 1
  218. while target_path.exists():
  219. filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.{note.id}.{dedupe_index}.md'
  220. target_path = notepath / filename
  221. dedupe_index += 1
  222. return target_path
  223. @click.command(
  224. context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
  225. )
  226. @click.option(
  227. "--user",
  228. "-u",
  229. prompt=True,
  230. required=True,
  231. envvar="GKEEP_USER",
  232. show_envvar=True,
  233. help="Google account email (prompt if empty)",
  234. )
  235. @click.option(
  236. "--password",
  237. "-p",
  238. prompt=True,
  239. required=True,
  240. envvar="GKEEP_PASSWORD",
  241. show_envvar=True,
  242. help="Google account password (prompt if empty)",
  243. hide_input=True,
  244. )
  245. @click.option(
  246. "--directory",
  247. "-d",
  248. default="./gkeep-export",
  249. show_default=True,
  250. help="Output directory for exported notes",
  251. type=click.Path(file_okay=False, dir_okay=True, writable=True),
  252. )
  253. @click.option(
  254. "--header/--no-header",
  255. default=True,
  256. show_default=True,
  257. help="Choose to include or exclude the frontmatter header",
  258. )
  259. @click.option(
  260. "--delete-local/--no-delete-local",
  261. default=False,
  262. show_default=True,
  263. help="Choose to delete or leave as-is any notes that exist locally but not in Google Keep",
  264. )
  265. @click.option(
  266. "--rename-local/--no-rename-local",
  267. default=False,
  268. show_default=True,
  269. help="Choose to rename or leave as-is any notes that change titles in Google Keep",
  270. )
  271. @click.option(
  272. "--date-format",
  273. default="%Y-%m-%d",
  274. show_default=True,
  275. help="Date format to use for the prefix of the note filenames. Reflects the created date of the note.",
  276. )
  277. @click.option(
  278. "--skip-existing-media/--no-skip-existing-media",
  279. default=True,
  280. show_default=True,
  281. help="Skip existing media if it appears unchanged from the local copy.",
  282. )
  283. def main(
  284. directory: str,
  285. user: str,
  286. password: str,
  287. header: bool,
  288. delete_local: bool,
  289. rename_local: bool,
  290. date_format: str,
  291. skip_existing_media: bool,
  292. ):
  293. """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
  294. notepath = pathlib.Path(directory).resolve()
  295. mediapath = notepath.joinpath("media/")
  296. click.echo(f"Notes directory: {notepath}")
  297. click.echo(f"Media directory: {mediapath}")
  298. click.echo("Logging in.")
  299. keep = login(user, password)
  300. if not notepath.exists():
  301. click.echo("Notes directory does not exist, creating.")
  302. notepath.mkdir(parents=True)
  303. if not mediapath.exists():
  304. click.echo("Media directory does not exist, creating.")
  305. mediapath.mkdir(parents=True)
  306. click.echo("Indexing local files.")
  307. local_index = index_existing_files(notepath)
  308. click.echo("Indexing remote notes.")
  309. keep_notes = dict([(note.id, note) for note in keep.all()])
  310. deleted_notes = set(local_index.keys()).difference(set(keep_notes.keys()))
  311. if deleted_notes:
  312. if not delete_local:
  313. click.echo(
  314. f"{len(deleted_notes)} notes exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
  315. )
  316. else:
  317. click.echo(
  318. f"{len(deleted_notes)} notes exist locally, but not in Google Keep. Trashing local files."
  319. )
  320. for note_id in deleted_notes:
  321. click.echo(
  322. f" Deleting unknown local note [{note_id}] file [{local_index[note_id].path}]"
  323. )
  324. local_index[note_id].path.unlink()
  325. for note in keep_notes.values(): # type: gkeepapi.node.TopLevelNode
  326. if note.id in local_index:
  327. click.echo(f"Updating existing file for note {note.id}")
  328. else:
  329. click.echo(f"Downloading new note {note.id}")
  330. target_path = build_note_unique_path(notepath, note, date_format, local_index)
  331. if note.id in local_index:
  332. if rename_local and local_index[note.id].path != target_path:
  333. target_path = try_rename_note(local_index[note.id], target_path)
  334. else:
  335. target_path = local_index[note.id].path
  336. images = download_images(keep, note, mediapath, skip_existing_media)
  337. markdown = build_markdown(note, images)
  338. with target_path.open("wb+") as f:
  339. if header:
  340. fmatter = build_frontmatter(note, markdown)
  341. frontmatter.dump(fmatter, f)
  342. else:
  343. f.write(markdown.encode("utf-8"))
  344. click.echo("Done.")
  345. if __name__ == "__main__":
  346. main()