cli.py 9.1 KB

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