From 31f538c7f594fa80e1227df474dca334ca54ff18 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Wed, 2 Sep 2020 21:45:17 +0200 Subject: [PATCH] Add python cli --- .gitignore | 3 ++ cli/Config.py | 38 ++++++++++++++ cli/SimpleNotesApi.py | 66 +++++++++++++++++++++++++ cli/SimpleNotesCli.py | 112 ++++++++++++++++++++++++++++++++++++++++++ cli/domain.py | 12 +++++ cli/jwtutils.py | 10 ++++ cli/requirements.txt | 6 +++ cli/utils.py | 32 ++++++++++++ 8 files changed, 279 insertions(+) create mode 100644 cli/Config.py create mode 100644 cli/SimpleNotesApi.py create mode 100755 cli/SimpleNotesCli.py create mode 100644 cli/domain.py create mode 100644 cli/jwtutils.py create mode 100644 cli/requirements.txt create mode 100644 cli/utils.py diff --git a/.gitignore b/.gitignore index fa745c3..ce5c20c 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ app/src/main/resources/static/styles* # lucene index .lucene/ + +# python +__pycache__ diff --git a/cli/Config.py b/cli/Config.py new file mode 100644 index 0000000..1c744b7 --- /dev/null +++ b/cli/Config.py @@ -0,0 +1,38 @@ +import configparser +import os +from pathlib import Path + +import appdirs +import click + + +class Config: + def __init__(self): + config_dir: str = appdirs.user_config_dir("SimpleNotesCli") + Path(config_dir).mkdir(exist_ok=True) + self.file = os.path.join(config_dir, "config.ini") + self.configparser = configparser.ConfigParser() + + self.token = None + self.base_url = None + + def load(self): + self.configparser.read(self.file) + self.token = self.configparser.get("DEFAULT", "token", fallback=None) + self.base_url = self.configparser.get( + "DEFAULT", "base_url", fallback="https://simplenotes.be" + ) + + def save(self): + if self.token: + self.configparser.set("DEFAULT", "token", self.token) + + if self.base_url: + self.configparser.set("DEFAULT", "base_url", self.base_url) + + try: + with open(self.file, "w") as f: + self.configparser.write(f) + except IOError: + click.secho("An error occurred while saving config", fg="red", err=True) + exit(1) diff --git a/cli/SimpleNotesApi.py b/cli/SimpleNotesApi.py new file mode 100644 index 0000000..3897a00 --- /dev/null +++ b/cli/SimpleNotesApi.py @@ -0,0 +1,66 @@ +from typing import List, Optional + +import click +import requests +from requests.models import Response + +from domain import NoteMetadata + + +class SimplenotesApi: + def __init__(self, base_url: str): + self.base_url = base_url + self.s = requests.Session() + self.s.hooks["response"] = [self.__exit_unauthorized] + + def __url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def __exit_unauthorized(self, response: Response, *args, **kwargs): + if response.status_code == 401: + click.secho("Unauthorized, please login again", fg="red", err=True) + exit(1) + + def login(self, username: str, password: str) -> Optional[str]: + url = self.__url("/api/login") + r = self.s.post( + url, + json={"username": username, "password": password}, + ) + if r.status_code == 200: + return r.json()["token"] + + def set_token(self, token: str): + self.s.headers.update({"Authorization": f"Bearer {token}"}) + + def find_note(self, uuid: str) -> Optional[str]: + url = self.__url(f"/api/notes/{uuid}") + r = self.s.get(url) + if r.status_code == 200: + return r.json()["markdown"] + + def list_notes(self) -> List[NoteMetadata]: + url = self.__url("/api/notes") + r = self.s.get(url) + return list(map(lambda x: NoteMetadata(**x), r.json())) + + def search_notes(self, query: str) -> List[NoteMetadata]: + url = self.__url("/api/notes/search") + r = self.s.post(url, json={"query": query}) + + if r.status_code == 200: + j = r.json() + return list(map(lambda x: NoteMetadata(**x), j)) + else: + return [] + + def create_note(self, content: str) -> Optional[str]: + url = self.__url("/api/notes/") + r = self.s.post(url, json={"content": content}) + if r.status_code == 200: + return r.json()["uuid"] + + def update_note(self, uuid: str, content: str) -> bool: + url = self.__url(f"/api/notes/{uuid}") + r = self.s.put(url, json={"content": content}) + return r.status_code == 200 diff --git a/cli/SimpleNotesCli.py b/cli/SimpleNotesCli.py new file mode 100755 index 0000000..2e4cdd0 --- /dev/null +++ b/cli/SimpleNotesCli.py @@ -0,0 +1,112 @@ +import click +from click import Context + +import SimpleNotesApi +import utils +from Config import Config +from jwtutils import is_expired +from utils import edit_md + +api: SimpleNotesApi +base_url: str +conf = Config() + + +@click.group() +@click.pass_context +def cli(ctx: Context): + global base_url + global api + + conf.load() + base_url = conf.base_url + api = SimpleNotesApi.SimplenotesApi(base_url) + + if ctx.invoked_subcommand == "login" or ctx.invoked_subcommand == "config": + return + + token = conf.token + if token is None: + click.secho("Please login", err=True, fg="red") + exit(1) + elif is_expired(token): + click.secho("Login expired, please login again", err=True, fg="red") + exit(1) + else: + api.set_token(token) + + +@cli.command() +@click.option("--username", prompt=True) +@click.option("--password", prompt=True, hide_input=True) +def login(username: str, password: str): + token = api.login(username, password) + if token: + conf.token = token + conf.save() + click.secho(f"Welcome {username}", fg="green") + else: + click.echo("Invalid credentials") + exit(1) + + +@cli.command() +@click.option("--url", prompt=True) +def config(url: str): + conf.base_url = url + conf.save() + + +@cli.command(name="list") +def list_notes(): + utils.print_notes(api.list_notes()) + + +@cli.command() +@click.argument("uuid") +def edit(uuid: str): + note = api.find_note(uuid) + if not note: + click.secho("Note not found", err=True, fg="red") + exit(1) + + edited = edit_md(note) + + if edited == note: + exit(1) + + if not api.update_note(uuid, edited): + click.secho("An error occurred", err=True, fg="red") + exit(1) + else: + utils.print_note_url(uuid, "updated", conf.base_url) + + +@cli.command() +def new(): + placeholder = "---\ntitle: ''\ntags: []\n---\n" + md = edit_md(placeholder) + if md == placeholder: + exit(1) + uuid = api.create_note(md) + if uuid: + utils.print_note_url(uuid, "created", conf.base_url) + else: + click.secho("An error occurred", err=True, fg="red") + exit(1) + + +@cli.command(name="search") +@click.argument("search", nargs=-1, required=True) +def search_notes(search: str): + query = " ".join(search) + notes = api.search_notes(query) + + if not notes: + print("No match") + else: + utils.print_notes(notes) + + +if __name__ == "__main__": + cli() diff --git a/cli/domain.py b/cli/domain.py new file mode 100644 index 0000000..0efc602 --- /dev/null +++ b/cli/domain.py @@ -0,0 +1,12 @@ +from typing import List + + +class NoteMetadata: + def __init__(self, uuid: str, title: str, tags: List[str], updatedAt: str): + self.uuid = uuid + self.title = title + self.tags = tags + self.updated_at = updatedAt + + def __str__(self) -> str: + return f"NoteMetadata(uuid={self.uuid}, title={self.title}, tags={self.tags})" diff --git a/cli/jwtutils.py b/cli/jwtutils.py new file mode 100644 index 0000000..d44887b --- /dev/null +++ b/cli/jwtutils.py @@ -0,0 +1,10 @@ +from datetime import datetime + +import jwt + + +def is_expired(token: str) -> bool: + exp = jwt.decode(token, verify=False)["exp"] + dt = datetime.utcfromtimestamp(exp) + now = datetime.utcnow() + return dt < now diff --git a/cli/requirements.txt b/cli/requirements.txt new file mode 100644 index 0000000..f6403f0 --- /dev/null +++ b/cli/requirements.txt @@ -0,0 +1,6 @@ +requests==2.24.0 +python-editor==1.0.4 +click==7.1.2 +PyJWT==1.7.1 +appdirs==1.4.4 +tabulate==0.8.7 diff --git a/cli/utils.py b/cli/utils.py new file mode 100644 index 0000000..3f741ee --- /dev/null +++ b/cli/utils.py @@ -0,0 +1,32 @@ +from typing import List + +import editor +import click +from tabulate import tabulate + +from domain import NoteMetadata + + +def edit_md(content: str = "") -> str: + b = bytes(content, "utf-8") + result = editor.edit(contents=b, suffix=".md") + return result.decode("utf-8") + + +def print_notes(notes: List[NoteMetadata]): + data = [] + for n in notes: + uuid = click.style(n.uuid, fg="blue") + title = click.style(n.title, fg="green", bold=True) + tags = ["#" + e for e in n.tags] + tags = " ".join(tags) + data.append([uuid, title, tags]) + + headers = ["UUID", "title", "tags"] + click.echo(tabulate(data, headers=headers)) + + +def print_note_url(uuid: str, action: str, base_url: str): + url = f"{base_url}/notes/{uuid}" + s = click.style(url, fg="green", underline=True) + click.echo(f"Note {action}: {s}")