cli.py 8.5 KB

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