#!/usr/bin/env python3 import os import re import sys import json import argparse import textwrap import subprocess import rich # For some reason this is not imported by the command above. from rich.console import Console from rich.columns import Columns class Widget: def __init__(self, order, group = None): self.order = order self.group = group class Tasker(Widget): def __init__(self, show_only, order, group = None, touched = []): super().__init__(order, group) self.show_only = show_only self.touched = touched def __call__(self, task): raise NotImplementedError class Stacker(Widget): def __init__(self, tasker, order, group): super().__init__(order, group) self.tasker = tasker def __call__(self, tasks): raise NotImplementedError class Sectioner(Widget): def __init__(self, stacker, order, group): super().__init__(order, group) self.stacker = stacker def __call__(self, tasks): raise NotImplementedError class task: class Card(Tasker): def __init__(self, show_only, order = None, touched = [], wrap_width = 25, tag_icon = "+"): super().__init__(show_only, order, group = None, touched = touched) self.wrap_width = wrap_width self.tag_icon = tag_icon def _make(self, task): if not self.show_only: # Show all existing fields. self.show_only = task.keys() sid = str(task["id"]) if ':' in task["description"]: short, desc = task["description"].split(":") title = rich.text.Text(sid, style='id') + rich.text.Text(":", style="default") + rich.text.Text(short.strip(), style='short_description') desc = rich.text.Text("\n".join(textwrap.wrap(desc.strip(), self.wrap_width)), style='long_description') elif len(task["description"]) <= self.wrap_width - 8: d = task["description"].strip() title = rich.text.Text(sid, style='id') + rich.text.Text(":", style="default") + rich.text.Text(d, style='short_description') desc = None else: desc = task["description"] desc = rich.text.Text("\n".join(textwrap.wrap(desc.strip(), self.wrap_width)), style='description') title = rich.text.Text(sid, style='id') segments = [] for key in self.show_only: if key in task.keys() and key not in ["id", "description"]: val = task[key] segment = f"{key}: " if type(val) == str: segments.append(rich.text.Text(segment+t, style=key)) elif type(val) == list: # FIXME Columns does not fit. # g = Columns([f"+{t}" for t in val], expand = False) g = rich.console.Group(*[rich.text.Text(f"{self.tag_icon}{t}", style=key) for t in val], fit = True) segments.append(g) else: segments.append(rich.text.Text(segment+str(val), style=key)) # FIXME Columns does not fit. # cols = Columns(segments) cols = rich.console.Group(*segments, fit = True) if desc: body = rich.console.Group(desc, cols, fit = True) else: body = cols return title,body def __call__(self, task): title, body = self._make(task) sid = str(task["id"]) if sid in self.touched: panel = rich.panel.Panel(body, title = title, title_align="left", expand = False, padding = (0,1), border_style = 'touched', box = rich.box.DOUBLE_EDGE) else: panel = rich.panel.Panel(body, title = title, title_align="left", expand = False, padding = (0,1)) return panel class Sheet(Card): def __init__(self, show_only, order = None, touched = [], wrap_width = 25, tag_icon = "🏷 "): super().__init__(show_only, order, touched = touched, wrap_width = wrap_width, tag_icon = tag_icon) def __call__(self, task): title, body = self._make(task) t = rich.text.Text("\n ", style="") + title + rich.text.Text(" ") sid = str(task["id"]) if sid in self.touched: b = rich.panel.Panel(body, box = rich.box.SIMPLE_HEAD, style='touched') else: b = rich.panel.Panel(body, box = rich.box.SIMPLE_HEAD, style='description') sheet = rich.console.Group(t,b) return sheet class Raw(Tasker): def __init__(self, show_only, order = None, touched = []): super().__init__(show_only, order, group = None, touched = touched) def __call__(self, task): if not self.show_only: # Show all existing fields. self.show_only = task.keys() raw = {} for k in self.show_only: if k in task: raw[k] = task[k] else: raw[k] = None return raw class stack: class RawTable(Stacker): def __init__(self, tasker): super().__init__(tasker, order = None, group = None) def __call__(self, tasks): keys = self.tasker.show_only table = rich.table.Table(box = None, show_header = False, show_lines = True, expand = True, row_styles=['row_odd', 'row_even']) table.add_column('H') for k in keys: table.add_column(k) for task in tasks: taskers = self.tasker(task) if str(task['id']) in self.tasker.touched: row = [rich.text.Text('▶', style = 'touched')] else: row = [''] for k in keys: if k in task: val = taskers[k] if type(val) == str: if k == 'description' and ':' in val: short, desc = val.split(':') # FIXME groups add a newline or hide what follows, no option to avoid it. # row.append( rich.console.Group( # rich.text.Text(short+':', style='short_description', end='\n'), # rich.text.Text(desc, style='description', end='\n') # )) # FIXME style leaks on all texts: row.append( rich.text.Text(short, style='short_description', end='') + \ rich.text.Text(':', style='default', end='') + \ rich.text.Text(desc, style='long_description', end='') ) else: row.append( rich.text.Text(val, style=k) ) elif type(val) == list: # FIXME use Columns if does not expand. row.append( rich.text.Text(" ".join(val), style=k) ) else: row.append( rich.text.Text(str(val), style=k) ) else: row.append("") table.add_row(*[t for t in row]) return table class Vertical(Stacker): def __init__(self, tasker): super().__init__(tasker, order = None, group = None) def __call__(self, tasks): stack = rich.table.Table(box = None, show_header = False, show_lines = False, expand = True) stack.add_column("Tasks") for task in tasks: stack.add_row( self.tasker(task) ) return stack class Flat(Stacker): def __init__(self, tasker): super().__init__(tasker, order = None, group = None) def __call__(self, tasks): stack = [] for task in tasks: stack.append( self.tasker(task) ) cols = rich.columns.Columns(stack) return cols class sections: class Vertical(Sectioner): def __init__(self, stacker, order, group): super().__init__(stacker, order, group) def __call__(self, tasks): sections = [] groups = self.group(tasks) for key in self.order(): if key in groups: sections.append( rich.panel.Panel(self.stacker(groups[key]), title = rich.text.Text(key.upper(), style=key), title_align = "left", expand = False)) return rich.console.Group(*sections) class Horizontal(Sectioner): def __init__(self, stacker, order, group): super().__init__(stacker, order, group) def __call__(self, tasks): sections = [] groups = self.group(tasks) table = rich.table.Table(box = None, show_header = False, show_lines = False) keys = [] for key in self.order(): if key in groups: table.add_column(key) keys.append(key) row = [] for k in keys: row.append( rich.panel.Panel(self.stacker(groups[k]), title = rich.text.Text(k.upper(), style=k), title_align = "left", expand = True, border_style="title")) table.add_row(*row) return table class SectionSorter: def __call__(self): raise NotImplementedError class sort: class Tasks: def make(self, tasks, field, reverse = False): return sorted(tasks, key = lambda t: t[field], reverse = reverse) class OnValues(SectionSorter): def __init__(self, values): self.values = values def __call__(self): return self.values class Grouper: """Group tasks by field values.""" def __init__(self, field): self.field = field def __call__(self, tasks): groups = {} for task in tasks: if self.field in task: if task[self.field] in groups: groups[ task[self.field] ].append(task) else: groups[ task[self.field] ] = [task] return groups class group: class Status(Grouper): def __init__(self): super().__init__("status") def __call__(self, tasks): groups = {} for task in tasks: if "start" in task: if "started" in groups: groups["started"].append(task) else: groups["started"] = [task] elif self.field in task: if task[self.field] in groups: groups[ task[self.field] ].append(task) else: groups[ task[self.field] ] = [task] return groups def call_taskwarrior(args:list[str] = ['export']) -> str: # Local file. env = os.environ.copy() env["TASKDATA"] = asked.data cmd = ['task'] + args try: p = subprocess.Popen( " ".join(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, env=env, ) out, err = p.communicate() except subprocess.CalledProcessError as exc: print("ERROR:", exc.returncode, exc.output, err) sys.exit(exc.returncode) else: return out.decode('utf-8') def get_data(): out = call_taskwarrior(['export']) try: jdata = json.loads(out) except json.decoder.JSONDecodeError as exc: print("ERROR:", exc.returncode, exc.output) else: return jdata def parse_touched(out): return re.findall('[ModifyingCreated]+ task ([0-9]+)', out) def get_swatches(name = None): swatches = { "none": { 'touched': '', 'id': '', 'title': '', 'description': '', 'short_description': '', 'long_description': '', 'entry': '', 'modified': '', 'started': '', 'status': '', 'uuid': '', 'tags': '', 'urgency': '', 'row_odd': '', 'row_even' : '', }, "nojhan": { 'touched': '#4E9A06', 'id': 'color(214)', 'title': '', 'description': 'color(231)', 'short_description': 'color(231)', 'long_description': 'default', 'entry': '', 'modified': 'color(240)', 'started': '', 'status': 'bold italic white', 'uuid': '', 'tags': 'color(33)', 'urgency': 'color(219)', 'row_odd': 'on #262121', 'row_even' : 'on #2d2929', }, "chalky": { 'touched': 'color(0) on color(15)', 'id': 'bold color(160) on white', 'title': '', 'description': 'black on white', 'short_description': 'bold black on white', 'long_description': 'black on white', 'entry': '', 'modified': 'color(240)', 'started': '', 'status': 'bold italic white', 'uuid': '', 'tags': 'color(166) on white', 'urgency': 'color(219)', 'row_odd': 'on #262121', 'row_even' : 'on #2d2929', }, } if name: return swatches[name] else: return swatches def get_layouts(kind = None, name = None): # FIXME use introspection to extract that automatically. available = { "task": { "Raw": task.Raw, "Card": task.Card, "Sheet": task.Sheet, }, "stack": { "RawTable": stack.RawTable, "Vertical": stack.Vertical, "Flat": stack.Flat, }, "sections": { "Vertical": sections.Vertical, "Horizontal": sections.Horizontal, }, } if kind and name: return available[kind][name] elif kind: return available[kind] elif not kind and not name: return available else: raise KeyError("cannot get layouts with 'name' only") if __name__ == "__main__": parser = argparse.ArgumentParser( description="XXX", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("-s", "--show", metavar="columns", type=str, default="id,description,tags", nargs=1, help="Ordered list of columns to show.") config_grp = parser.add_argument_group('configuration options') config_grp.add_argument("-d", "--data", metavar="FILE", type=str, default=".task", nargs=1, help="The input data file.") config_grp.add_argument("--list-separator", metavar="CHARACTER", type=str, default=",", nargs=1, help="Separator used for lists that are passed as options arguments.") layouts_grp = parser.add_argument_group('layout options') layouts_grp.add_argument('-t', '--layout-task', metavar='NAME', type=str, default='Raw', choices = get_layouts('task').keys(), help="Layout managing tasks.") layouts_grp.add_argument('-k', '--layout-stack', metavar='NAME', type=str, default='RawTable', choices = get_layouts('stack').keys(), help="Layout managing stacks.") layouts_grp.add_argument('-c', '--layout-sections', metavar='NAME', type=str, default='Horizontal', choices = get_layouts('sections').keys(), help="Layout managing sections.") layouts_grp.add_argument('-T', '--swatch', metavar='NAME', type=str, default='none', choices = get_swatches().keys(), help="Color chart.") layouts_grp.add_argument('--card-wrap', metavar="NB", type=int, default=25, help="Number of character at which to wrap the description of Cards tasks.") # Capture whatever remains. parser.add_argument('cmd', nargs="*") asked = parser.parse_args() # First pass arguments to taskwarrior and let it do its magic. out = call_taskwarrior(asked.cmd) if "Description" not in out: print(out.strip()) touched = parse_touched(out) # print("Touched:",touched) # Then get the resulting data. jdata = get_data() # print(json.dumps(jdata, indent=4)) showed = asked.show.split(asked.list_separator) if not showed: show_only = None else: show_only = showed swatch = rich.theme.Theme(get_swatches(asked.swatch)) layouts = get_layouts() if asked.layout_task == "Card" or asked.layout_task == "Sheet": tasker = layouts['task'][asked.layout_task](show_only, touched = touched, wrap_width = asked.card_wrap) else: tasker = layouts['task'][asked.layout_task](show_only, touched = touched) stacker = layouts['stack'][asked.layout_stack](tasker) group_by_status = group.Status() sort_on_values = sort.OnValues(["pending","started","completed"]) sectioner = layouts['sections'][asked.layout_sections](stacker, sort_on_values, group_by_status) console = Console(theme = swatch) # console.rule("taskwarrior-deluxe") console.print(sectioner(jdata))