| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- #!/usr/bin/env python3
- """keep_exporter command line interface module. Provides the actual user interactions to `export.py`."""
- import pathlib
- from typing import Any, Optional, Union
- import click
- import click_config_file
- import gkeepapi
- from configobj import ConfigObj
- from keep_exporter.export import (
- LocalNote,
- build_markdown,
- build_note_unique_path,
- delete_local_only_files,
- download_media,
- index_existing_files,
- try_rename_note,
- write_note,
- )
- __version__ = "2.0.1"
- __author__ = "Nathan Beals, Matthew Bafford"
- APP_NAME = "Keep Exporter"
- def login(
- user_email: str, password: Optional[str], token: Optional[str] = None
- ) -> gkeepapi.Keep:
- """Logs in with the given email and password or token.
- Args:
- user_email (str): user's google email address
- password (Optional[str]): account password, either this or `token` are *required*
- token (Optional[str], optional): account token, either this or `password` are **required**. Defaults to None.
- Raises:
- click.BadParameter: Login failed
- Returns:
- gkeepapi.Keep: the `keep <gkeepapi.Keep>` object
- """
- keep = gkeepapi.Keep()
- if token:
- try:
- click.echo("Logging in with token")
- keep.resume(user_email, token)
- return keep
- except gkeepapi.exception.LoginException as ex:
- raise click.BadParameter(f"Token login (resume) failed: {str(ex)}")
- if password:
- try:
- click.echo("Logging in with password")
- keep.login(user_email, password)
- return keep
- except gkeepapi.exception.LoginException as ex:
- raise click.BadParameter(f"Password login failed: {str(ex)}")
- raise click.BadParameter("Neither password nor token provided to login.")
- def get_click_supplied_value(ctx: click.core.Context, param_name: str) -> Any:
- """Find the value passed to Click through the following priority:
- #1 - a parameter passed on the command line
- #2 - a config value passed through @click_config_file
- #3 - None
- """
- # I didn't find in the docs for Click a simpler way to get the
- # parameter if specified, fall back to the default_map if not, None if neither
- # but this feels like a standard thing that should be built-in
- if param_name in ctx.params:
- return ctx.params[param_name]
- if ctx.default_map:
- return ctx.default_map.get(param_name)
- return None
- def token_callback_password_or_token(
- ctx: click.core.Context,
- _param: Union[click.core.Option, click.core.Parameter],
- value: Any,
- ) -> Any:
- """
- On the token param (after password), ensure that either a password
- or token were supplied, and if neither was, prompt for the password.
- """
- if value:
- token = value
- else:
- token = get_click_supplied_value(ctx, "token")
- password = get_click_supplied_value(ctx, "password")
- if not token and not password:
- click.echo("Neither password nor token provided. Prompting for password")
- password = click.prompt("Password", hide_input=True)
- ctx.params["password"] = password
- return None
- return token
- def date_format_handler(
- ctx: click.core.Context,
- param: Union[click.core.Option, click.core.Parameter],
- value: Any,
- ) -> str:
- """handles the various flags that modify `date_format`
- Args:
- ctx (click.core.Context): App context
- param (Union[click.core.Option, click.core.Parameter]): the current flag/option modifying date format
- value (Any): The value of said option, not really used
- Returns:
- str: the option value to set
- """
- if param.name == "date_format":
- if (
- ctx.params.get("date_format") and param.default == value
- ): # If date_format is already set by some other flag, return that same data
- return ctx.params.get("date_format")
- else:
- return value # otherwise, set the format to the passed value
- else:
- if value:
- ctx.params["date_format"] = param.metavar
- return value
- @click.group(
- invoke_without_command=True,
- # invoke_without_command=False,
- context_settings={"max_content_width": 160, "help_option_names": ["-h", "--help"]},
- )
- @click.pass_context
- @click_config_file.configuration_option(
- config_file_name=click.get_app_dir(APP_NAME),
- default=click.get_app_dir(APP_NAME),
- show_default=True,
- expose_value=True,
- )
- @click.option(
- "--user",
- "-u",
- prompt=True,
- required=True,
- envvar="GKEEP_USER",
- show_envvar=True,
- help="Google account email (prompt if empty)",
- )
- @click.option(
- "--password",
- "-p",
- envvar="GKEEP_PASSWORD",
- show_envvar=True,
- help="Google account password (prompt if empty). Either this or token is required.",
- hide_input=True,
- )
- @click.option(
- "--token",
- "-t",
- envvar="GKEEP_TOKEN",
- help="Google account token from prior run. Either this or password is required.",
- callback=token_callback_password_or_token,
- )
- @click.option(
- "--directory",
- "-d",
- default="./gkeep-export",
- show_default=True,
- help="Output directory for exported notes",
- type=click.Path(file_okay=False, dir_okay=True, writable=True),
- )
- @click.option(
- "--header/--no-header",
- default=True,
- show_default=True,
- help="Choose to include or exclude the frontmatter header",
- )
- @click.option(
- "--delete-local/--no-delete-local",
- default=False,
- show_default=True,
- help="Choose to delete or leave as-is any notes that exist locally but not in Google Keep",
- )
- @click.option(
- "--rename-local/--no-rename-local",
- default=False,
- show_default=True,
- help="Choose to rename or leave as-is any notes that change titles in Google Keep",
- )
- @click.option( # Other date-options (e.g --iso8601) must come after this one
- "--date-format",
- "date_format",
- default="%Y-%m-%d",
- is_flag=False,
- show_default=True,
- help="Date format to prefix the note filenames. Reflects the created date of the note. uses strftime()",
- callback=date_format_handler,
- )
- @click.option(
- "--iso8601",
- metavar="%Y-%m-%dT%H:%M:%S", # use `metavar` for the format instead of default or flag_value, hack
- default=False,
- is_flag=True,
- help="Format dates in ISO8601 format.",
- callback=date_format_handler,
- )
- @click.option(
- "--skip-existing-media/--no-skip-existing-media",
- default=True,
- show_default=True,
- help="Skip existing media if it appears unchanged from the local copy.",
- )
- def main(
- ctx,
- directory: str,
- user: str,
- password: Optional[str],
- token: Optional[str],
- header: bool,
- delete_local: bool,
- rename_local: bool,
- date_format: str,
- skip_existing_media: bool,
- # iso8601: Any,
- # config: str, # required to be here, despite being as-of-yet unused.
- **_kwargs,
- ):
- """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
- notepath = pathlib.Path(directory).resolve()
- mediapath = notepath.joinpath("media/")
- if ctx.invoked_subcommand is not None:
- return False
- click.echo(f"Notes directory: {notepath}")
- click.echo(f"Media directory: {mediapath}")
- keep = login(user, password, token)
- if not notepath.exists():
- click.echo("Notes directory does not exist, creating.")
- notepath.mkdir(parents=True)
- if not mediapath.exists():
- click.echo("Media directory does not exist, creating.")
- mediapath.mkdir(parents=True)
- click.echo("Indexing local files.")
- local_index = index_existing_files(notepath)
- click.echo("Indexing remote notes.")
- keep_notes = dict([(note.id, note) for note in keep.all()])
- skipped_notes, updated_notes, new_notes = 0, 0, 0
- downloaded_media = 0
- deleted_notes, deleted_media = delete_local_only_files(
- local_index, keep_notes, delete_local
- )
- for note in keep_notes.values(): # type: gkeepapi._node.Note
- local_note = local_index.get(note.id)
- if not local_note:
- click.echo(f"Downloading new note {note.id}")
- new_notes += 1
- target_path = build_note_unique_path(notepath, note, date_format, local_index)
- local_path = local_index.get(note.id, LocalNote(note.id)).path
- if local_path:
- if rename_local and local_path != target_path:
- target_path = try_rename_note(local_index[note.id], target_path)
- else:
- target_path = local_path
- # decide to skip after the rename (due to date format change) has a chance
- if local_note:
- if local_note.timestamp_updated == note.timestamps.updated:
- skipped_notes += 1
- continue
- else:
- updated_notes += 1
- click.echo(f"Updating existing file for note {note.id}")
- images, downloaded = download_media(keep, note, mediapath, skip_existing_media)
- markdown = build_markdown(note, images)
- downloaded_media += downloaded
- write_note(target_path, header, note, markdown)
- click.echo("Finished syncing.")
- click.echo(
- f"Notes: {skipped_notes} unchanged, {updated_notes} updated, {new_notes} new, {deleted_notes} deleted"
- )
- click.echo(f"Media: {downloaded_media} downloaded, {deleted_media} deleted")
- @main.command()
- @click.pass_context
- def savetoken(ctx):
- """Saves the master token to your configuration file. Avoids re-logging in every time an export happens."""
- user, password, _token = (
- ctx.parent.params.get("user", ""),
- ctx.parent.params.get("password", ""),
- ctx.parent.params.get("token", ""),
- )
- keep = login(user, password)
- click.echo("Saving master token.")
- config_file = ctx.parent.params.get("config", None)
- if config_file:
- config_obj = ConfigObj(config_file, unrepr=True)
- if keep.getMasterToken() != config_obj.get("token", ""):
- config_obj["token"] = keep.getMasterToken()
- config_obj.write()
- click.echo("Master token written to configuration file.")
- if __name__ == "__main__":
- # pylint: disable=no-value-for-parameter
- main()
|