cli.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import pathlib
  2. from typing import Any, Optional, Union, Tuple
  3. import click
  4. import click_config_file
  5. import frontmatter
  6. import gkeepapi
  7. # from .export import *
  8. from keep_exporter.export import (
  9. LocalNote,
  10. build_frontmatter,
  11. build_markdown,
  12. build_note_unique_path,
  13. delete_local_only_files,
  14. download_media,
  15. index_existing_files,
  16. # login,
  17. try_rename_note,
  18. )
  19. # import keep_exporter.export as export
  20. def login(
  21. user_email: str, password: Optional[str], token: Optional[str]
  22. ) -> Tuple[gkeepapi.Keep, str]:
  23. keep = gkeepapi.Keep()
  24. if token:
  25. try:
  26. click.echo("Logging in with token")
  27. keep.resume(user_email, token)
  28. # print(keep.getMasterToken())
  29. return (keep, "")
  30. except gkeepapi.exception.LoginException as ex:
  31. raise click.BadParameter(f"Token login (resume) failed: {str(ex)}")
  32. if password:
  33. try:
  34. click.echo("Logging in with password")
  35. keep.login(user_email, password)
  36. # print(keep.getMasterToken())
  37. return (keep, keep.getMasterToken())
  38. except gkeepapi.exception.LoginException as ex:
  39. raise click.BadParameter(f"Password login failed: {str(ex)}")
  40. raise click.BadParameter(f"Neither password nor token provided to login.")
  41. def get_click_supplied_value(ctx: click.core.Context, param_name: str) -> Any:
  42. """
  43. Find the value passed to Click through the following priority:
  44. #1 - a parameter passed on the command line
  45. #2 - a config value passed through @click_config_file
  46. #3 - None
  47. """
  48. # I didn't find in the docs for Click a simpler way to get the
  49. # parameter if specified, fall back to the default_map if not, None if neither
  50. # but this feels like a standard thing that should be built-in
  51. if param_name in ctx.params:
  52. return ctx.params[param_name]
  53. if ctx.default_map:
  54. return ctx.default_map.get(param_name)
  55. return None
  56. def token_callback_password_or_token(
  57. ctx: click.core.Context,
  58. param: Union[click.core.Option, click.core.Parameter],
  59. value: Any,
  60. ) -> Any:
  61. """
  62. On the token param (after password), ensure that either a password
  63. or token were supplied, and if neither was, prompt for the password.
  64. """
  65. if value:
  66. token = value
  67. else:
  68. token = get_click_supplied_value(ctx, "token")
  69. password = get_click_supplied_value(ctx, "password")
  70. if not token and not password:
  71. click.echo("Neither password nor token provided. Prompting for password")
  72. password = click.prompt("Password", hide_input=True)
  73. ctx.params["password"] = password
  74. return None
  75. return token
  76. @click.command(
  77. context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
  78. )
  79. @click_config_file.configuration_option()
  80. @click.option(
  81. "--user",
  82. "-u",
  83. prompt=True,
  84. required=True,
  85. envvar="GKEEP_USER",
  86. show_envvar=True,
  87. help="Google account email (prompt if empty)",
  88. )
  89. @click.option(
  90. "--password",
  91. "-p",
  92. envvar="GKEEP_PASSWORD",
  93. show_envvar=True,
  94. help="Google account password (prompt if empty). Either this or token is required.",
  95. hide_input=True,
  96. )
  97. @click.option(
  98. "--token",
  99. "-t",
  100. envvar="GKEEP_TOKEN",
  101. help="Google account token from prior run. Either this or password is required.",
  102. callback=token_callback_password_or_token,
  103. )
  104. @click.option(
  105. "--directory",
  106. "-d",
  107. default="./gkeep-export",
  108. show_default=True,
  109. help="Output directory for exported notes",
  110. type=click.Path(file_okay=False, dir_okay=True, writable=True),
  111. )
  112. @click.option(
  113. "--header/--no-header",
  114. default=True,
  115. show_default=True,
  116. help="Choose to include or exclude the frontmatter header",
  117. )
  118. @click.option(
  119. "--delete-local/--no-delete-local",
  120. default=False,
  121. show_default=True,
  122. help="Choose to delete or leave as-is any notes that exist locally but not in Google Keep",
  123. )
  124. @click.option(
  125. "--rename-local/--no-rename-local",
  126. default=False,
  127. show_default=True,
  128. help="Choose to rename or leave as-is any notes that change titles in Google Keep",
  129. )
  130. @click.option(
  131. "--date-format",
  132. default="%Y-%m-%d",
  133. show_default=True,
  134. help="Date format to use for the prefix of the note filenames. Reflects the created date of the note.",
  135. )
  136. @click.option(
  137. "--skip-existing-media/--no-skip-existing-media",
  138. default=True,
  139. show_default=True,
  140. help="Skip existing media if it appears unchanged from the local copy.",
  141. )
  142. def main(
  143. directory: str,
  144. user: str,
  145. password: Optional[str],
  146. token: Optional[str],
  147. header: bool,
  148. delete_local: bool,
  149. rename_local: bool,
  150. date_format: str,
  151. skip_existing_media: bool,
  152. ):
  153. """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
  154. notepath = pathlib.Path(directory).resolve()
  155. mediapath = notepath.joinpath("media/")
  156. click.echo(f"Notes directory: {notepath}")
  157. click.echo(f"Media directory: {mediapath}")
  158. keep, new_token = login(user, password, token)
  159. if len(new_token) > 0:
  160. pass # save new token, where/how?
  161. if not notepath.exists():
  162. click.echo("Notes directory does not exist, creating.")
  163. notepath.mkdir(parents=True)
  164. if not mediapath.exists():
  165. click.echo("Media directory does not exist, creating.")
  166. mediapath.mkdir(parents=True)
  167. click.echo("Indexing local files.")
  168. local_index = index_existing_files(notepath)
  169. click.echo("Indexing remote notes.")
  170. keep_notes = dict([(note.id, note) for note in keep.all()])
  171. skipped_notes, updated_notes, new_notes = 0, 0, 0
  172. downloaded_media = 0
  173. deleted_notes, deleted_media = delete_local_only_files(
  174. local_index, keep_notes, delete_local
  175. )
  176. for note in keep_notes.values(): # type: gkeepapi._node.Note
  177. local_note = local_index.get(note.id)
  178. if not local_note:
  179. click.echo(f"Downloading new note {note.id}")
  180. new_notes += 1
  181. target_path = build_note_unique_path(notepath, note, date_format, local_index)
  182. local_path = local_index.get(note.id, LocalNote(note.id)).path
  183. if local_path:
  184. if rename_local and local_path != target_path:
  185. target_path = try_rename_note(local_index[note.id], target_path)
  186. else:
  187. target_path = local_path
  188. # decide to skip after the rename (due to date format change) has a chance
  189. if local_note:
  190. if local_note.timestamp_updated == note.timestamps.updated:
  191. skipped_notes += 1
  192. continue
  193. else:
  194. updated_notes += 1
  195. click.echo(f"Updating existing file for note {note.id}")
  196. images, downloaded = download_media(keep, note, mediapath, skip_existing_media)
  197. markdown = build_markdown(note, images)
  198. downloaded_media += downloaded
  199. with target_path.open("wb+") as f:
  200. if header:
  201. fmatter = build_frontmatter(note, markdown)
  202. frontmatter.dump(fmatter, f)
  203. else:
  204. f.write(markdown.encode("utf-8"))
  205. click.echo("Finished syncing.")
  206. click.echo(
  207. f"Notes: {skipped_notes} unchanged, {updated_notes} updated, {new_notes} new, {deleted_notes} deleted"
  208. )
  209. click.echo(f"Media: {downloaded_media} downloaded, {deleted_media} deleted")
  210. if __name__ == "__main__":
  211. # pylint: disable=no-value-for-parameter
  212. main()