| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- import pathlib
- from typing import Any, Optional, Union, Tuple
- import click
- import click_config_file
- from configobj import ConfigObj
- import frontmatter
- import gkeepapi
- # from .export import *
- from keep_exporter.export import (
- LocalNote,
- build_frontmatter,
- build_markdown,
- build_note_unique_path,
- delete_local_only_files,
- download_media,
- index_existing_files,
- # login,
- try_rename_note,
- )
- APP_NAME = "Keep Exporter"
- # import keep_exporter.export as export
- def login(
- user_email: str, password: Optional[str], token: Optional[str] = ""
- ) -> Tuple[gkeepapi.Keep, str]:
- keep = gkeepapi.Keep()
- if token:
- try:
- click.echo("Logging in with token")
- keep.resume(user_email, token)
- # print(keep.getMasterToken())
- return (keep, keep.getMasterToken())
- 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)
- # print(keep.getMasterToken())
- return (keep, keep.getMasterToken())
- except gkeepapi.exception.LoginException as ex:
- raise click.BadParameter(f"Password login failed: {str(ex)}")
- raise click.BadParameter(f"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 conf_callback(a,b,c):
- print("callb",a,b,c)
- # @click.command(
- # context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
- # )
- @click.group(invoke_without_command=True,
- context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
- )
- @click.pass_context
- @click_config_file.configuration_option(
- config_file_name=click.get_app_dir(APP_NAME),
- # config_file_name="test",
- # implicit=False,
- # callback=conf_callback,
- 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(
- "--date-format",
- default="%Y-%m-%d",
- show_default=True,
- help="Date format to use for the prefix of the note filenames. Reflects the created date of the note.",
- )
- @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,
- config,
- 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,
- ):
- """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/")
- print(click.get_app_dir(APP_NAME))
- # print(click.get_current_context())
- # print(click.get_current_context().cmd_info)
- # print(click.get_app_dir(click.get_current_context().cmd_info))
- # exit()
- ctx.ensure_object(dict)
- click.echo(f"Notes directory: {notepath}")
- click.echo(f"Media directory: {mediapath}")
- # keep, new_token = login(user, password, token)
- # if len(new_token) > 0:
- # pass # save new token, where/how?
- # 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
- # with target_path.open("wb+") as f:
- # if header:
- # fmatter = build_frontmatter(note, markdown)
- # frontmatter.dump(fmatter, f)
- # else:
- # f.write(markdown.encode("utf-8"))
- # 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, new_token = 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.")
- # print('done')
- if __name__ == "__main__":
- # pylint: disable=no-value-for-parameter
- main()
|