#!/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 ` 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()