Add python cli
This commit is contained in:
parent
c7cf71441f
commit
31f538c7f5
3
.gitignore
vendored
3
.gitignore
vendored
@ -133,3 +133,6 @@ app/src/main/resources/static/styles*
|
|||||||
|
|
||||||
# lucene index
|
# lucene index
|
||||||
.lucene/
|
.lucene/
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__
|
||||||
|
|||||||
38
cli/Config.py
Normal file
38
cli/Config.py
Normal file
@ -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)
|
||||||
66
cli/SimpleNotesApi.py
Normal file
66
cli/SimpleNotesApi.py
Normal file
@ -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
|
||||||
112
cli/SimpleNotesCli.py
Executable file
112
cli/SimpleNotesCli.py
Executable file
@ -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()
|
||||||
12
cli/domain.py
Normal file
12
cli/domain.py
Normal file
@ -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})"
|
||||||
10
cli/jwtutils.py
Normal file
10
cli/jwtutils.py
Normal file
@ -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
|
||||||
6
cli/requirements.txt
Normal file
6
cli/requirements.txt
Normal file
@ -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
|
||||||
32
cli/utils.py
Normal file
32
cli/utils.py
Normal file
@ -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}")
|
||||||
Loading…
x
Reference in New Issue
Block a user