Просмотр исходного кода

Add support for specifying the config in a file, (partially) add resume token support (#14)

* Add support for specifying the config in a file, add resume token support (--token)

* Refactoring code. devs: run `poetry install` again

Reviewed-by: Nathan Beals <ndbeals@users.noreply.github.com>
Matthew Bafford 5 лет назад
Родитель
Сommit
e3bce0c785
6 измененных файлов с 291 добавлено и 157 удалено
  1. 5 1
      README.md
  2. 6 0
      config.example
  3. 223 0
      keep_exporter/cli.py
  4. 24 154
      keep_exporter/export.py
  5. 31 1
      poetry.lock
  6. 2 1
      pyproject.toml

+ 5 - 1
README.md

@@ -13,8 +13,12 @@ If you do not supply a username or password before running it, you will be promp
 ```
 Usage: keep_export [OPTIONS]
 Options:
+  --config FILE                   Read configuration from FILE.
   -u, --user TEXT                 Google account email (prompt if empty)  [env var: GKEEP_USER; required]
-  -p, --password TEXT             Google account password (prompt if empty)  [env var: GKEEP_PASSWORD; required]
+  -p, --password TEXT             Google account password (prompt if empty). Either this or token is required.  [env
+                                  var: GKEEP_PASSWORD]
+
+  -t, --token TEXT                Google account token from prior run. Either this or password is required.
   -d, --directory DIRECTORY       Output directory for exported notes  [default: ./gkeep-export]
   --header / --no-header          Choose to include or exclude the frontmatter header  [default: True]
   --delete-local / --no-delete-local

+ 6 - 0
config.example

@@ -0,0 +1,6 @@
+user = "username"
+password = "password"
+token = "token_obtained_from_gkeepapi"
+directory = "/tmp/keep_export/"
+rename_local = True
+delete_local = True

+ 223 - 0
keep_exporter/cli.py

@@ -0,0 +1,223 @@
+import pathlib
+from typing import Any, Optional, Union
+
+import click
+import frontmatter
+
+# 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,
+)
+# import keep_exporter.export as export
+
+
+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
+
+
+import click_config_file
+
+
+@click.command(
+    context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
+)
+@click_config_file.configuration_option()
+@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(
+    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/")
+
+    click.echo(f"Notes directory: {notepath}")
+    click.echo(f"Media directory: {mediapath}")
+
+    keep = login(user, password, token)
+    exit(0)
+
+    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")
+
+
+if __name__ == "__main__":
+    # pylint: disable=no-value-for-parameter
+    main()

+ 24 - 154
keep_exporter/export.py

@@ -2,9 +2,10 @@
 import datetime
 import mimetypes
 import pathlib
-from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union, ValuesView
+from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union, ValuesView, Any
 
 import click
+import click_config_file
 import frontmatter
 import gkeepapi
 from gkeepapi.node import NodeAudio, NodeDrawing, NodeImage
@@ -14,14 +15,29 @@ from pathvalidate import sanitize_filename
 mimetypes.add_type("audio/3gpp", ".3gp")
 
 
-def login(user_email: str, password: str) -> gkeepapi.Keep:
+def login(
+    user_email: str, password: Optional[str], token: Optional[str]
+) -> gkeepapi.Keep:
     keep = gkeepapi.Keep()
-    try:
-        keep.login(user_email, password)
-    except gkeepapi.exception.LoginException as ex:
-        raise click.BadParameter(f"Login failed: {str(ex)}")
-
-    return keep
+    if token:
+        try:
+            click.echo("Logging in with token")
+            keep.resume(user_email, token)
+            print(keep.getMasterToken())
+            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)
+            print(keep.getMasterToken())
+            return keep
+        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 all_note_media(
@@ -398,149 +414,3 @@ def delete_local_only_files(
 
     return (deleted_notes, deleted_media)
 
-
-@click.command(
-    context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
-)
-@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",
-    prompt=True,
-    required=True,
-    envvar="GKEEP_PASSWORD",
-    show_envvar=True,
-    help="Google account password (prompt if empty)",
-    hide_input=True,
-)
-@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(
-    directory: str,
-    user: str,
-    password: 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/")
-
-    click.echo(f"Notes directory: {notepath}")
-    click.echo(f"Media directory: {mediapath}")
-
-    click.echo("Logging in.")
-    keep = login(user, password)
-
-    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")
-
-
-if __name__ == "__main__":
-    main()

+ 31 - 1
poetry.lock

@@ -65,6 +65,18 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "click-config-file"
+version = "0.6.0"
+description = "Configuration file support for click applications."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+click = ">=6.7"
+configobj = ">=5.0.6"
+
 [[package]]
 name = "colorama"
 version = "0.4.4"
@@ -73,6 +85,17 @@ category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "configobj"
+version = "5.0.6"
+description = "Config file reading, writing and validation."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = "*"
+
 [[package]]
 name = "future"
 version = "0.18.2"
@@ -304,7 +327,7 @@ python-versions = "*"
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "1aa17b3ad74b68d634b2e52a41677a4a34b494b7e69ff477f4d4d7b9a376781d"
+content-hash = "6520fc65932d468f6f6dc45428ad23408ea06dc9bdf33da029697ba6fd0d0c9b"
 
 [metadata.files]
 appdirs = [
@@ -331,9 +354,16 @@ click = [
     {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
     {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
 ]
+click-config-file = [
+    {file = "click_config_file-0.6.0-py2.py3-none-any.whl", hash = "sha256:3c5802dec437ed596f181efc988f62b1069cd48a912e280cd840ee70580f39d7"},
+    {file = "click_config_file-0.6.0.tar.gz", hash = "sha256:ded6ec1a73c41280727ec9c06031e929cdd8a5946bf0f99c0c3db3a71793d515"},
+]
 colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
 ]
+configobj = [
+    {file = "configobj-5.0.6.tar.gz", hash = "sha256:a2f5650770e1c87fb335af19a9b7eb73fc05ccf22144eb68db7d00cd2bcb0902"},
+]
 future = [
     {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"},
 ]

+ 2 - 1
pyproject.toml

@@ -6,7 +6,7 @@ authors = ["Nathan Beals <ndbeals@users.noreply.github.com>"]
 license = "GPL v3"
 
 [tool.poetry.scripts]
-keep_export = "keep_exporter.export:main"
+keep_export = "keep_exporter.cli:main"
 
 [tool.poetry.dependencies]
 python = "^3.8"
@@ -16,6 +16,7 @@ PyYAML = "^5.3.1"
 pathvalidate = "^2.3.2"
 click = "^7.1.2"
 mdutils = "^1.3.0"
+click-config-file = "^0.6.0"
 
 [tool.poetry.dev-dependencies]
 black = {version = "^20.8b1", allow-prereleases = true}