4
0

cli.py 10 KB

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