154 lines
5.6 KiB
Python
154 lines
5.6 KiB
Python
import sys
|
|
|
|
import pandas as pd
|
|
import click
|
|
import tabulate
|
|
|
|
error_codes = {
|
|
"INVALID_KEY": 100,
|
|
"ID_NOT_FOUND": 101,
|
|
"UNKNOWN_STATUS": 102,
|
|
}
|
|
|
|
def error(id,msg):
|
|
print(msg)
|
|
sys.exit(error_codes[id])
|
|
|
|
def load_data(context):
|
|
# Automagically manages standard input if input=="-", thanks to allow_dash=True.
|
|
with click.open_file(context.obj['input'], mode='r') as fd:
|
|
df = pd.read_csv(fd)
|
|
# FIXME data sanity checks: unique index, aligned status, valid dates
|
|
|
|
df.set_index(context.obj['id_key'])
|
|
return df
|
|
|
|
def save_data(context, df):
|
|
# Automagically manages standard input if input=="-", thanks to allow_dash=True.
|
|
with click.open_file(context.obj['input'], mode='w') as fd:
|
|
df.to_csv(fd)
|
|
|
|
|
|
|
|
# Global group holding global options.
|
|
@click.group()
|
|
@click.option('-i', '--input', help="CSV data file.", default='.klyban.csv', type=click.Path(writable=True, readable=True, allow_dash=True), show_default=True)
|
|
@click.option('--status-key', help="Header key defining the status of tasks.", default='STATUS', type=str, show_default=True)
|
|
@click.option('--status-list', help="Comma-separated, ordered list of possible values for the status of tasks.", default='TODO,DOING,HOLD,DONE', type=str, show_default=True)
|
|
@click.option('--id-key', help="Header key defining the unique ID of tasks.", default='ID', type=str, show_default=True)
|
|
@click.option('--title-key', help="Header key defining the title (short description) of tasks.", default='TITLE', type=str, show_default=True)
|
|
@click.option('--details-key', help="Header key defining the details (long description) of tasks.", default='DETAILS', type=str, show_default=True)
|
|
@click.option('--tags-key', help="Header key defining the tags associated to tasks.", default='TAG', type=str, show_default=True)
|
|
@click.option('--deadline-key', help="Header key defining the deadlines tasks.", default='DEADLINE', type=str, show_default=True)
|
|
@click.option('--show-keys', help="Comman-separated, ordered list of fields that should be shown", default='ID,TITLE,DETAILS,DEADLINE,TAG', type=str, show_default=True)
|
|
@click.pass_context
|
|
def cli(context, input, status_key, status_list, id_key, title_key, details_key, tags_key, deadline_key, show_keys):
|
|
# ensure that context.obj exists and is a dict (in case `cli()` is called
|
|
# by means other than the `if` block below)
|
|
context.ensure_object(dict)
|
|
context.obj['input'] = input
|
|
|
|
context.obj['status_key'] = status_key
|
|
context.obj['id_key'] = id_key
|
|
context.obj['title_key'] = title_key
|
|
context.obj['details_key'] = details_key
|
|
context.obj['tags_key'] = tags_key
|
|
context.obj['deadline_key'] = deadline_key
|
|
|
|
context.obj['status_list'] = status_list.split(',')
|
|
context.obj['show_keys'] = show_keys.split(',')
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_context
|
|
def show(context):
|
|
"""Show the kanban."""
|
|
|
|
df = load_data(context)
|
|
|
|
tables = df.groupby(context.obj['status_key'])
|
|
for k in context.obj['status_list']: # Ordered.
|
|
if k in tables.groups:
|
|
df = tables.get_group(k)
|
|
# STATUS
|
|
print(k)
|
|
try:
|
|
t = df[context.obj['show_keys']]
|
|
except KeyError as e:
|
|
msg = ""
|
|
for k in context.obj['show_keys']:
|
|
if k not in df.columns:
|
|
msg += "cannot show field `{}`, not found in `{}` ".format(k, context.obj['input'])
|
|
error("INVALID_KEY", msg)
|
|
else:
|
|
print(tabulate.tabulate(t, headers=context.obj['show_keys'], tablefmt="fancy_grid", showindex=False))
|
|
|
|
@cli.command()
|
|
@click.argument('ID')
|
|
@click.pass_context
|
|
def promote(context, id):
|
|
"""Upgrade the status of task `ID` to the next one.
|
|
|
|
Use status configured with --status-list."""
|
|
|
|
df = load_data(context)
|
|
|
|
row = df.loc[ df[context.obj['id_key']] == int(id) ]
|
|
if row.empty:
|
|
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], id, context.obj['input']))
|
|
|
|
i=0
|
|
for i in range(len(context.obj['status_list'])):
|
|
if row[context.obj['status_key']][1] == context.obj['status_list'][i]:
|
|
break
|
|
else:
|
|
i += 1
|
|
if i >= len(context.obj['status_list'])-1:
|
|
error("UNKNOWN_STATUS", "Cannot promote task {}, already at the last status.".format(id))
|
|
else:
|
|
df.loc[df[context.obj['id_key']] == int(id), context.obj['status_key']] = context.obj['status_list'][i+1]
|
|
|
|
save_data(context, df)
|
|
|
|
context.invoke(show)
|
|
|
|
@cli.command()
|
|
@click.argument('ID')
|
|
@click.pass_context
|
|
def demote(context, id):
|
|
"""Downgrade the status of task `ID` to the previous one.
|
|
|
|
Use status configured with --status-list."""
|
|
|
|
df = load_data(context)
|
|
|
|
row = df.loc[ df[context.obj['id_key']] == int(id) ]
|
|
if row.empty:
|
|
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], id, context.obj['input']))
|
|
|
|
i=0
|
|
for i in range(len(context.obj['status_list'])):
|
|
if row[context.obj['status_key']][1] == context.obj['status_list'][i]:
|
|
break
|
|
else:
|
|
i += 1
|
|
if i == 0:
|
|
error("UNKNOWN_STATUS", "Cannot demote task {}, already at the first status.".format(id))
|
|
else:
|
|
df.loc[df[context.obj['id_key']] == int(id), context.obj['status_key']] = context.obj['status_list'][i-1]
|
|
|
|
save_data(context, df)
|
|
|
|
context.invoke(show)
|
|
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_context
|
|
def config(context):
|
|
"""Show the current configuration."""
|
|
click.echo('Configuration:')
|
|
click.echo(f"Data file: `{context.obj['input']}`")
|
|
|
|
if __name__ == '__main__':
|
|
cli(obj={})
|