cli.py 6.5 KB

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