taskwarrior-deluxe/klyban.py
2023-07-31 15:39:52 +02:00

905 lines
34 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sys
import csv
import json
import datetime
import textwrap
from configparser import ConfigParser
import numpy as np
import pandas as pd
import click
# import tabulate
import rich.console as rconsole
from rich.table import Table as richTable
from rich.text import Text as richText
from rich.panel import Panel as richPanel
from rich.columns import Columns as richColumns
from rich.theme import Theme as richTheme
from rich.layout import Layout as richLayout
from rich.console import Group as richGroup
from rich import box
error_codes = {
"INVALID_KEY": 100,
"ID_NOT_FOUND": 101,
"UNKNOWN_STATUS": 102,
}
def error(name,msg):
print("ERROR:",msg)
sys.exit(error_codes[name])
def load_data(context):
try:
# 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
except FileNotFoundError:
# Create an empty file.
df = pd.DataFrame(columns=[
context.obj['id_key'],
context.obj['status_key'],
context.obj['title_key'],
context.obj['details_key'],
context.obj['tags_key'],
context.obj['deadline_key'],
context.obj['touched_key'],
])
df = df.set_index(context.obj['id_key'])
save_data(context, df)
else:
# set index on ID.
df = df.astype({context.obj['id_key']:int})
df = df.set_index(context.obj['id_key'])
# Remove any values consisting of empty spaces or quotes.
df = df.replace(r'^[\s"\']*$', np.nan, regex=True)
finally:
# Virtual "hints" column.
df['H'] = ''
if context.obj['debug']:
print("Loaded:")
print(df)
return df
def save_data(context, df):
if context.obj['debug']:
print("Save:")
print(df)
# FIXME double check that there are actually data.
# Remove the virtual "hints" column.
df = df.drop('H', axis = 1)
# Bring back ID as a regular column.
df = df.reset_index()
# Remove any values consisting of empty spaces or quotes.
df = df.replace(r'^[\s"\']*$', np.nan, regex=True)
# 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, index=False, quoting=csv.QUOTE_NONNUMERIC)
def configure(context, param, filename):
"""Overwrite defaults for options."""
cfg = ConfigParser()
cfg.read(filename)
context.default_map = {}
for sect in cfg.sections():
command_path = sect.split('.')
if command_path[0] != 'options':
continue
defaults = context.default_map
for cmdname in command_path[1:]:
defaults = defaults.setdefault(cmdname, {})
defaults.update(cfg[sect])
def check_id(context, param, value):
"""Callback checking if task exists."""
if value is None: # For optional TID.
return value
assert(type(value) == int)
df = load_data(context)
if value not in df.index:
error("ID_NOT_FOUND", "{} `{}` was not found in data `{}`".format(context.obj['id_key'], value, context.obj['input']))
return value
# Global group holding global options.
@click.group(invoke_without_command=True)
# Core options.
@click.option(
'-c', '--config',
type = click.Path(dir_okay=False),
default = '.klyban.conf',
callback = configure,
is_eager = True,
expose_value = False,
help = 'Read option defaults from the specified configuration file.',
show_default = True,
)
@click.option('-i', '--input' , help="CSV data file.", default='.klyban.csv', type=click.Path(writable=True, readable=True, allow_dash=True), show_default=True)
# Display options.
@click.option('-h','--show-headers', is_flag=True, help="Show the headers.")
@click.option('-s', '--show-fields' , default='ID,TITLE,DETAILS,TAGS', type=str , show_default=True, help="Comma-separated, ordered list of fields that should be shown (use 'all' for everything).")
@click.option('-g', '--highlight', type = int, default = None, help="Highlight a specific task.")
@click.option('--highlight-mark', type = str, default = '', help="String used to highlight a specific task.")
@click.option('-l', '--layout', type = click.Choice(['vertical-compact', 'vertical-spaced', 'horizontal-compact', 'horizontal-spaced']), default = 'vertical-compact', help="How to display tasks.") # TODO , 'horizontal-compact', 'horizontal-spaced'
@click.option('-t', '--theme', type = click.Choice(['none', 'user', 'BW', 'BY', 'RW', 'nojhan'], case_sensitive=False), default = 'none', help="How to display tasks.")
# Low-level configuration options.
@click.option('--status-key' , default='STATUS' , type=str, show_default=True, help="Header key defining the status of tasks.")
@click.option('--status-list' , default='TODO,DOING,HOLD,DONE', type=str, show_default=True, help="Comma-separated, ordered list of possible values for the status of tasks.")
@click.option('--id-key' , default='ID' , type=str, show_default=True, help="Header key defining the unique ID of tasks.")
@click.option('--title-key' , default='TITLE' , type=str, show_default=True, help="Header key defining the title (short description) of tasks.")
@click.option('--details-key' , default='DETAILS' , type=str, show_default=True, help="Header key defining the details (long description) of tasks.")
@click.option('--tags-key' , default='TAGS' , type=str, show_default=True, help="Header key defining the tags associated to tasks.")
@click.option('--deadline-key', default='DEADLINE', type=str, show_default=True, help="Header key defining the deadlines tasks.")
@click.option('--touched-key', default='TOUCHED', type=str, show_default=True, help="Header key defining the deadlines tasks.")
@click.option('--debug', is_flag=True, help="Print debugging information.")
@click.pass_context
def cli(context, **kwargs):
# print(json.dumps(kwargs, sort_keys=True, indent=4))
# Ensure that context.obj exists and is a dict.
context.ensure_object(dict)
def store(context_key, kw_key = None):
if not kw_key:
kw_key = context_key
context.obj[context_key] = kwargs[kw_key]
store('input')
store('debug')
store('id_key')
store('status_key')
store('title_key')
store('details_key')
store('tags_key')
store('deadline_key')
store('touched_key')
store('show_headers')
store('highlight')
store('highlight_mark')
store('layout')
context.obj['layouts'] = {
'vertical-compact': VerticalCompact,
'vertical-spaceds': VerticalSpaced,
'horizontal-compact': HorizontalCompact,
'horizontal-spaced': HorizontalSpaced,
}
context.obj['themes'] = {
'none': richTheme({
'H': '',
'matching': 'italic',
context.obj['id_key']: '',
context.obj['status_key']: '',
context.obj['title_key']: '',
context.obj['details_key']: '',
context.obj['tags_key']: '',
context.obj['deadline_key']: '',
context.obj['touched_key']: '',
'row_odd': '',
'row_even': '',
}),
'BW': richTheme({
'H': 'white',
'matching': 'italic',
context.obj['id_key']: 'bold black',
context.obj['status_key']: 'bold',
context.obj['title_key']: 'bold white',
context.obj['details_key']: 'white',
context.obj['tags_key']: 'italic white',
context.obj['deadline_key']: 'white',
context.obj['touched_key']: 'black',
'row_odd': 'on color(237)',
'row_even': 'on color(239)',
}),
'BY': richTheme({
'H': 'color(220)',
'matching': 'italic',
context.obj['id_key']: 'bold color(39)',
context.obj['status_key']: 'bold color(227)',
context.obj['title_key']: 'color(220)',
context.obj['details_key']: 'color(33)',
context.obj['tags_key']: 'color(27)',
context.obj['deadline_key']: 'white',
context.obj['touched_key']: 'black',
'row_odd' : 'bold',
'row_even': '',
}),
'RW': richTheme({
'H': 'red',
'matching': 'italic',
context.obj['id_key']: 'bold red',
context.obj['status_key']: 'bold white',
context.obj['title_key']: 'bold white',
context.obj['details_key']: 'white',
context.obj['tags_key']: 'red',
context.obj['deadline_key']: 'white',
context.obj['touched_key']: 'color(240)',
'row_odd' : '',
'row_even': '',
}),
'nojhan': richTheme({
'H': '#4E9A06',
'matching': 'italic on #464141',
context.obj['id_key']: 'bold color(214)',
context.obj['status_key']: 'bold italic white',
context.obj['title_key']: 'bold white',
context.obj['details_key']: 'white',
context.obj['tags_key']: 'color(27)',
context.obj['deadline_key']: 'white',
context.obj['touched_key']: 'color(240)',
'row_odd': 'on #262121',
'row_even' : 'on #2d2929',
}),
}
context.obj['theme'] = context.obj['themes'][kwargs['theme']]
context.obj['status_list'] = kwargs['status_list'].split(',')
if kwargs['show_fields'].lower() == "all":
context.obj['show_fields'] = [
context.obj['id_key'],
context.obj['status_key'],
context.obj['title_key'],
context.obj['details_key'],
context.obj['tags_key'],
context.obj['deadline_key'],
context.obj['touched_key'],
]
else:
context.obj['show_fields'] = kwargs['show_fields'].split(',')
# Always show the 'Hint' column.
context.obj['show_fields'] = ['H'] + context.obj['show_fields']
# At the end, always load data, whatever the command will be.
context.obj['data'] = load_data(context)
# Finally, if no command: defaults to `show`.
if not context.invoked_subcommand:
context.invoke(show)
class Layout:
def __init__(self, context):
self.context = context
class Vertical(Layout):
def __init__(self, context, table_box, show_lines, panel_box):
super().__init__(context)
self.show_lines = show_lines
self.table_box = table_box
self.panel_box = panel_box
def section_prefix(self, sections):
pass
def section_suffix(self, sections):
pass
def section(self, section, table, sections):
# Title styling does not work because of bug #2466 in Rich, fixed after 32d6e99.
# See https://github.com/Textualize/rich/issues/2466
title = richText(section, style = self.context.obj['status_key'], overflow = 'ellipsis')
panel = richPanel(table, title = title, title_align="left", border_style = self.context.obj['status_key'], box = self.panel_box, expand = False)
sections.append(panel)
def __rich__(self):
df = self.context.obj['data']
# Show the kanban tables.
if df.empty:
return "No task."
sections = []
if self.context.obj['highlight'] is not None:
df.loc[self.context.obj['highlight'], 'H'] = self.contex.obj['highlight_mark']
# Group by status.
tables = df.groupby(self.context.obj['status_key'])
# Loop over the asked ordered status groups.
for section in self.context.obj['status_list']: # Ordered.
if section in tables.groups:
df = tables.get_group(section)
# Bring back TID as a regular column.
df = df.reset_index().fillna("")
# Always consider the hint column.
if 'H' not in self.context.obj['show_fields']:
self.context.obj['show_fields'] = ['H'] + self.context.obj['show_fields']
try:
# Print asked columns.
t = df[self.context.obj['show_fields']]
except KeyError as e:
msg = ""
for section in self.context.obj['show_fields']:
if section not in df.columns:
msg += "cannot show field `{}`, not found in `{}` ".format(section, self.context.obj['input'])
error("INVALID_KEY", msg)
else:
if len(df.index) <= 1:
show_lines = False
table_box = None
else:
show_lines = self.show_lines
table_box = self.table_box
table = richTable(show_header = self.context.obj['show_headers'], box = table_box, row_styles = ['row_odd', 'row_even'], show_lines = show_lines, expand = True)
for h in self.context.obj['show_fields']:
table.add_column(h, style = h)
for i,row in t.iterrows():
items = (str(row[k]) for k in self.context.obj['show_fields'])
if row['H']:
row_style = 'matching'
else:
row_style = None
table.add_row(*items, style = row_style)
self.section_prefix(sections)
self.section(section, table, sections)
self.section_suffix(sections)
return rconsole.Group(*sections)
class VerticalCompact(Vertical):
def __init__(self, context):
super().__init__(context, table_box = None, show_lines = False, panel_box = box.ROUNDED)
# FIXME find a way to use console.rule instead of richPanel in self.section
class VerticalSpaced(Vertical):
def __init__(self, context):
super().__init__(context, table_box = box.HORIZONTALS, show_lines = True, panel_box = box.HEAVY_EDGE)
def section_prefix(self, sections):
sections.append("\n")
class Horizontal(Layout):
def __init__(self, context, table_box, show_lines, panel_box):
super().__init__(context)
self.show_lines = show_lines
self.table_box = table_box
self.panel_box = panel_box
class HorizontalCompact(Horizontal):
def __init__(self, context):
super().__init__(context, table_box = None, show_lines = False, panel_box = box.ROUNDED)
def __rich__(self):
df = self.context.obj['data']
# Show the kanban tables.
if df.empty:
return "No task."
sections = []
if self.context.obj['highlight'] is not None:
df.loc[self.context.obj['highlight'], 'H'] = self.contex.obj['highlight_mark']
# Group by status.
tables = df.groupby(self.context.obj['status_key'])
# Loop over the asked ordered status groups.
for section in self.context.obj['status_list']: # Ordered.
if section in tables.groups:
df = tables.get_group(section)
# Bring back TID as a regular column.
df = df.reset_index().fillna("")
# Always consider the hint column.
if 'H' not in self.context.obj['show_fields']:
self.context.obj['show_fields'] = ['H'] + self.context.obj['show_fields']
console = rconsole.Console()
try:
# Print asked columns.
t = df[self.context.obj['show_fields']]
except KeyError as e:
msg = ""
for section in self.context.obj['show_fields']:
if section not in df.columns:
msg += "cannot show field `{}`, not found in `{}` ".format(section, self.context.obj['input'])
error("INVALID_KEY", msg)
else:
if len(df.index) <= 1:
show_lines = False
table_box = None
else:
show_lines = self.show_lines
table_box = self.table_box
table = richTable(show_header = self.context.obj['show_headers'], box = table_box, row_styles = ['row_odd', 'row_even'], show_lines = show_lines, expand = True)
for h in self.context.obj['show_fields']:
table.add_column(h, style = h)
for i,row in t.iterrows():
items = (str(row[k]) for k in self.context.obj['show_fields'])
if row['H']:
row_style = 'matching'
else:
row_style = None
table.add_row(*items, style = row_style)
title = richText(section, style = self.context.obj['status_key'], overflow = 'ellipsis')
panel = richPanel(table, title = title, title_align="left", border_style = self.context.obj['status_key'], box = self.panel_box, expand = True, padding = (0,0))
sections.append(richLayout(panel, name = section))
layout = richLayout()
layout.split_row(*sections)
# FIXME ugly hack: pre-render the englobing panel, then count the number of "non empty" lines.
fakepan = richPanel(layout, box = box.SIMPLE, border_style = 'none')
console = rconsole.Console(theme = self.context.obj['theme'], no_color = True)
with console.capture() as capture:
console.print(fakepan)
lines = capture.get().split('\n')
nb_lines = 0
for line in lines:
letters = set(line)
if letters != set({'m', '[', '\x1b', '', '1', '3', '0', ' ', ';'}):
nb_lines += 1
# FIXME get rid of the space padding added by the panel, even without border.
superpan = richPanel(layout, height = nb_lines, box = box.SIMPLE, border_style = 'none')
return superpan
class HorizontalSpaced(Horizontal):
def __init__(self, context):
super().__init__(context, table_box = None, show_lines = False, panel_box = box.ROUNDED)
def __rich__(self):
df = self.context.obj['data']
# Show the kanban tables.
if df.empty:
return "No task."
sections = []
if self.context.obj['highlight'] is not None:
df.loc[self.context.obj['highlight'], 'H'] = self.contex.obj['highlight_mark']
# Group by status.
tables = df.groupby(self.context.obj['status_key'])
# Loop over the asked ordered status groups.
for section in self.context.obj['status_list']: # Ordered.
if section in tables.groups:
df = tables.get_group(section)
# Bring back TID as a regular column.
df = df.reset_index().fillna("")
# Always consider the hint column.
if 'H' not in self.context.obj['show_fields']:
self.context.obj['show_fields'] = ['H'] + self.context.obj['show_fields']
console = rconsole.Console()
try:
# Print asked columns.
t = df[self.context.obj['show_fields']]
except KeyError as e:
msg = ""
for section in self.context.obj['show_fields']:
if section not in df.columns:
msg += "cannot show field `{}`, not found in `{}` ".format(section, self.context.obj['input'])
error("INVALID_KEY", msg)
else:
if len(df.index) <= 1:
show_lines = False
table_box = None
else:
show_lines = self.show_lines
table_box = self.table_box
table = richTable(show_header = False, box = None, show_lines = False, expand = True, row_styles = ['row_odd', 'row_even'])
table.add_column('')
# One task_table per task.
for i,task in t.iterrows():
task_table = richTable(show_header = self.context.obj['show_headers'], box = table_box, show_lines = show_lines, expand = True, padding = (0,0))
# One column.
task_table.add_column('')
nb_title_keys = 0
for h in self.context.obj['show_fields']:
if h in ['H', self.context.obj['id_key'], self.context.obj['title_key']]:
nb_title_keys += 1
task_title = []
for h in self.context.obj['show_fields']:
item = str(task[h])
if h == 'H':
task_title.append(richText(task['H'], style = 'H'))
if len(task_title) == nb_title_keys:
task_table.add_row(task_title[0]+task_title[1]+' '+task_title[2], style = h)
task_title = []
continue
elif h == self.context.obj['id_key']:
task_title.append(richText(item, style = h))
if len(task_title) == nb_title_keys:
task_table.add_row(task_title[0]+task_title[1]+' '+task_title[2], style = h)
task_title = []
continue
elif h == self.context.obj['title_key']:
task_title.append(richText(item, style = h))
if len(task_title) == nb_title_keys:
task_table.add_row(task_title[0]+task_title[1]+' '+task_title[2], style = h)
task_title = []
continue
if len(task_title) == nb_title_keys:
task_table.add_row(task_title[0]+task_title[1]+' '+task_title[2], style = h)
task_title = []
else:
task_table.add_row(item, style = h)
# Add one final row for spacing,
# using a non-breakable space to bypass fakepan row filtering.
task_table.add_row('')
if task['H']:
row_style = 'matching'
else:
row_style = None
table.add_row(task_table, style = row_style)
title = richText(section, style = self.context.obj['status_key'], overflow = 'ellipsis')
panel = richPanel(table, title = title, title_align="left", border_style = self.context.obj['status_key'], box = self.panel_box, expand = True, padding = (0,0))
sections.append(richLayout(panel, name = section))
layout = richLayout()
layout.split_row(*sections)
# FIXME ugly hack: pre-render the englobing panel, then count the number of "non empty" lines.
fakepan = richPanel(layout, box = box.SIMPLE, border_style = 'none')
console = rconsole.Console(theme = self.context.obj['theme'], no_color = True)
with console.capture() as capture:
console.print(fakepan)
lines = capture.get().split('\n')
nb_lines = 0
for line in lines:
letters = set(line)
if letters != set({'m', '[', '\x1b', '', '1', '3', '0', ' ', ';'}):
nb_lines += 1
# FIXME get rid of the space padding added by the panel, even without border.
superpan = richPanel(layout, height = nb_lines, box = box.SIMPLE, border_style = 'none')
return superpan
@cli.command()
@click.argument('TID', required=False, type=int, is_eager=True, callback=check_id)
@click.pass_context
def show(context, tid):
"""Show a task card (if ID is passed) or the whole the kanban (else)."""
# Because commands invoked before may alter the table,
# we need to reload the data.
df = load_data(context)
if tid is None:
layout = context.obj['layouts'][context.obj['layout']](context)
console = rconsole.Console(theme = context.obj['theme'])
console.print(layout)
else: # tid is not None.
# Show a task card.
row = df.loc[tid]
console = rconsole.Console()
table = richTable(box = None, show_header = False)
table.add_column("Task")
def add_row_text(table, key, icon = ''):
if context.obj[key] in context.obj['show_fields']:
if str(row[context.obj[key]]) != "nan": # FIXME WTF?
table.add_row(icon + row[context.obj[key]])
else:
return
def add_row_list(table, key = context.obj['tags_key'], icon = ''):
if context.obj[key] in context.obj['show_fields']:
if str(row[context.obj[key]]) != "nan": # FIXME WTF?
tags = [icon+t for t in row[context.obj[key]].split(',')]
columns = richColumns(tags, expand = False)
table.add_row(columns)
else:
return
add_row_text(table, 'details_key')
add_row_list(table, 'tags_key', '🏷 ')
add_row_text(table, 'deadline_key', '🗓')
add_row_text(table, 'touched_key', ':calendar-text:')
# Label content.
l = []
if context.obj['id_key'] in context.obj['show_fields']:
l.append(str(tid))
if context.obj['title_key'] in context.obj['show_fields']:
l.append(row[context.obj['title_key']])
label = ": ".join(l)
panel = richPanel.fit(table, title = label, title_align="left")
console.print(panel)
@cli.command()
@click.argument('TITLE', required=True, nargs=-1)
@click.option('-d', '--details' , type=str, prompt=True)
@click.option('-t', '--tags' , type=str, prompt=True)
@click.option('-a', '--deadline', type=str, prompt=True)
@click.option('-s', '--status' , type=str, default='TODO')
@click.pass_context
def add(context, title, status, details, tags, deadline):
"""Add a new task."""
df = context.obj['data']
if df.index.empty:
next_id = 0
else:
next_id = df.index.max() + 1
df.loc[next_id] = pd.Series({
context.obj['status_key']: status,
context.obj['title_key']: " ".join(title),
context.obj['details_key']: details,
context.obj['tags_key']: tags,
context.obj['deadline_key']: deadline,
context.obj['touched_key']: datetime.datetime.now().isoformat(),
})
save_data(context,df)
context.obj['highlight'] = next_id
context.invoke(show)
def default_from_existing(key):
class OptionDefaultFromContext(click.Option):
def get_default(self, context):
tid = context.params['tid']
df = context.obj['data']
assert(tid in df.index)
row = df.loc[tid]
value = row[context.obj[key]]
if str(value) != "nan": # FIXME WTF?
self.default = value
else:
self.default = ""
return super(OptionDefaultFromContext, self).get_default(context)
return OptionDefaultFromContext
@cli.command()
@click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.option('-t', '--title' , type=str, prompt=True, cls = default_from_existing('title_key'))
@click.option('-s', '--status' , type=str, prompt=True, cls = default_from_existing('status_key'))
@click.option('-d', '--details' , type=str, prompt=True, cls = default_from_existing('details_key'))
@click.option('-t', '--tags' , type=str, prompt=True, cls = default_from_existing('tags_key'))
@click.option('-a', '--deadline', type=str, prompt=True, cls = default_from_existing('deadline_key'))
@click.pass_context
def edit(context, tid, title, status, details, tags, deadline):
"""Add a new task."""
df = context.obj['data']
assert(tid in df.index)
df.loc[tid] = pd.Series({
context.obj['status_key']: status,
context.obj['title_key']: title,
context.obj['details_key']: details,
context.obj['tags_key']: tags,
context.obj['deadline_key']: deadline,
context.obj['touched_key']: datetime.datetime.now().isoformat(),
})
save_data(context,df)
context.obj['highlight'] = tid
context.invoke(show)
def check_yes(context, param, value):
"""Callback cheking for explicit user consent."""
if not value:
context.abort()
return value
@cli.command()
@click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.option('-y', '--yes', is_flag=True, expose_value=False, callback=check_yes, prompt="Permanently remove task from records?")
@click.pass_context
def remove(context, tid):
"""Delete a task."""
df = context.obj['data']
df = df.drop(index=tid)
save_data(context, df)
context.invoke(show)
def change_status(context, tid, new_status):
"""Edit the status of a task."""
df = context.obj['data']
row = df.loc[tid]
if row.empty:
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], tid, context.obj['input']))
if new_status not in context.obj['status_list']:
error("UNKNOWN_STATUS", "Unknown status `{}`".format(new_status))
else:
df.loc[tid, context.obj['status_key']] = new_status
df.loc[tid, context.obj['touched_key']] = datetime.datetime.now().isoformat()
save_data(context, df)
@cli.command()
@click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.argument('STATUS', required=True, type=str)
@click.pass_context
def status(context, tid, status):
"""Explicitely change the status of a task.
Use status names configured with --status-list."""
change_status(context, tid, status)
context.obj['highlight'] = tid
context.invoke(show)
@cli.command()
@click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.pass_context
def promote(context, tid):
"""Upgrade the status of a task to the next one.
Use status names configured with --status-list."""
df = context.obj['data']
row = df.loc[tid]
if row.empty:
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], tid, context.obj['input']))
i=0
for i in range(len(context.obj['status_list'])):
if row[context.obj['status_key']] == 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(tid))
else:
change_status(context, tid, context.obj['status_list'][i+1])
context.obj['highlight'] = tid
context.invoke(show)
@cli.command()
@click.argument('TID', required=True, type=int, is_eager=True, callback=check_id)
@click.pass_context
def demote(context, tid):
"""Downgrade the status of a task to the previous one.
Use status names configured with --status-list."""
df = context.obj['data']
row = df.loc[tid]
if row.empty:
error("ID_NOT_FOUND", "{} = {} not found in `{}`".format(context.obj['id_key'], tid, context.obj['input']))
i=0
for i in range(len(context.obj['status_list'])):
if row[context.obj['status_key']] == context.obj['status_list'][i]:
break
else:
i += 1
if i == 0:
error("UNKNOWN_STATUS", "Cannot demote task {}, already at the first status.".format(tid))
else:
change_status(context, tid, context.obj['status_list'][i-1])
context.obj['highlight'] = tid
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']}`")
@cli.command()
@click.argument('REGEX', required=True, type=str)
@click.option('-a', '--all' , is_flag = True, type = bool, default = False, help="Search even in hidden fields.")
@click.pass_context
def filter(context, regex, all):
"""Only show the tasks for which showed columns do contains a string matching the given regexp.
Example: klyban filter '[Aa]nd'"""
df = context.obj['data']
# Bring back TID as a regular *string* column.
df = df.reset_index().fillna("").astype('string')
# Filter mask.
if all:
mask = np.column_stack([ df[col].str.contains(regex, na=False) for col in df ] )
else:
mask = np.column_stack([ df[col].str.contains(regex, na=False) for col in df[context.obj['show_fields']] ] )
# Update in context for `show` to see.
context.obj['data'] = df.loc[mask.any(axis=1)]
context.invoke(show)
@cli.command()
@click.argument('REGEX', required=True, type=str)
@click.option('-m', '--mark', type = str, default = '', help="String used to highlight matching tasks.")
@click.option('-a', '--all' , is_flag = True, type = bool, default = False, help="Search even in hidden fields.")
@click.pass_context
def find(context, regex, mark, all):
"""Point out tasks containing a string matching the given regexp in any of the showed columns.
Example: klyban find '[Aa]nd'"""
df = context.obj['data']
# Bring back TID as a regular *string* column.
df = df.reset_index().fillna("").astype('string')
# Filter mask.
if all:
mask = np.column_stack([ df[col].str.contains(regex, na=False) for col in df ] )
else:
mask = np.column_stack([ df[col].str.contains(regex, na=False) for col in df[context.obj['show_fields']] ] )
# Mark out matching tasks.
df.loc[mask.any(axis=1), 'H'] = mark
# Update in context for `show` to see.
context.obj['data'] = df
context.invoke(show)
if __name__ == '__main__':
cli(obj={})