diff options
Diffstat (limited to 'scripts')
-rw-r--r-- | scripts/.gitignore | 1 | ||||
-rwxr-xr-x | scripts/reqs2tex.py | 173 | ||||
-rw-r--r-- | scripts/tex.py | 81 | ||||
-rwxr-xr-x | scripts/time2tex.py | 214 |
4 files changed, 469 insertions, 0 deletions
diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py new file mode 100755 index 0000000..2bab558 --- /dev/null +++ b/scripts/reqs2tex.py @@ -0,0 +1,173 @@ +#!/bin/python3 +import sys, tomllib, tex, re +from enum import StrEnum + +def label2ref(*labels): + return ",".join(["req:" + label for label in labels]) + +class KEY(StrEnum): + LABEL = 'label' + TYPE = 'type' + ID = 'id' + INDEX = 'index' + DELETED = 'deleted' + DONE = 'done' + DESCRIPTION = 'description' + PRIORITY = 'priority' + +REQ_TYPE = [ + 'system', + 'user', +] + +REQ_PRIORITY = [ + 'must', + 'should', + 'could', + 'will not', +] + +id_counter = 0 +def make_id(item): + global id_counter + id_counter += 1 + return "{type_short}#{counter:03d}".format( + type_short = item[KEY.TYPE][0].upper(), + counter = id_counter, + ) + +def sanitize(item, ids): + def die(msg): + print(f"[{item[KEY.LABEL]}]: {msg}") + exit(1) + + # ensure properties + item[KEY.DESCRIPTION] = item.get(KEY.DESCRIPTION) + item[KEY.DONE] = item.get(KEY.DONE) + item[KEY.PRIORITY] = item.get(KEY.PRIORITY) + item[KEY.TYPE] = item.get(KEY.TYPE) + + # type checks + if item[KEY.TYPE] not in REQ_TYPE: + die(f"unknown or missing requirement type: {repr(item[KEY.TYPE])}") + if item[KEY.PRIORITY] not in REQ_PRIORITY: + die(f"unknown or missing requirement priority: {repr(item[KEY.PRIORITY])}") + + # conversions + if isinstance(item[KEY.DONE], list): + # safety check + if not set(item[KEY.DONE]).issubset(ids): + die("definition of done includes unknown requirement(s)") + item[KEY.DONE] = tex.cmd('Cref', label2ref(*item[KEY.DONE])) + +def convert(reqs): + all_ids = [item[KEY.LABEL] for item in reqs] + index = 0 + for item in reqs: + item[KEY.ID] = tex.esc(make_id(item)) + item[KEY.DELETED] = item.get(KEY.DELETED, False) + if item[KEY.DELETED]: continue + item[KEY.INDEX] = index + index += 1 + sanitize(item, all_ids) + + # skip deleted requirements (but process for make_id) + reqs = [item for item in reqs if item[KEY.DELETED] == False] + + # sort by label + reqs = sorted(reqs, key=lambda item: item[KEY.LABEL]) + + return reqs + +def fmt_aux(data): + out = [] + for item in data: + ref = label2ref(item[KEY.LABEL]) + out += [ + tex.cmd('newlabel', f"{ref}", tex.group( + item[KEY.ID], + '', + '', + ref, + '', + )), + tex.cmd('newlabel', f"{ref}@cref", tex.group( + f"[requirement][][]{item[KEY.ID]}", + '[][][]', + '', + '', + '', + )), + ] + return "\n".join(out) + +def fmt_tex(data): + out = "" + for item in data: + out += tex.join( + tex.cmd('subsection', f"{item[KEY.ID]}: {item[KEY.LABEL]}".upper()), + tex.withatletter( + tex.cmd('cref@constructprefix', 'requirement', r'\cref@result'), + tex.pedef('@currentlabel', item[KEY.ID]), + tex.pedef('@currentlabelname', item[KEY.ID]), + tex.pedef('cref@currentlabel', tex.group(['requirement'], [''], [r'\cref@result']) + item[KEY.ID]), + ), + tex.cmd('label', ['requirement'], label2ref(item[KEY.LABEL])), + tex.cmd('parbox', tex.cmd('linewidth'), + tex.env('description', tex.join( + tex.cmd('item', [tex.cmd('reqlabel', 'priority')]), + item[KEY.PRIORITY].title(), + tex.cmd('item', [tex.cmd('reqlabel', 'description')]), + item[KEY.DESCRIPTION], + *([ + tex.cmd('item', [tex.cmd('reqlabel', 'done')]), + item[KEY.DONE] + ] if item[KEY.DONE] is not None else []), + )), + ) + ) + return out + +def tomlload(content): + # replace requirement labels with temp value + label_map = dict() + label_idx = 0 + lines = content.split("\n") + for index, line in enumerate(lines): + match = re.search(r"^\s*\[(.+)\]", line) + if match is None: continue + lines[index] = f"[{label_idx}]" + label_map[str(label_idx)] = match.group(1) + label_idx += 1 + content = "\n".join(lines) + + # load TOML and replace temporary labels with real labels + data_dict = tomllib.loads(content) + data_list = [] + for key, value in data_dict.items(): + value[KEY.LABEL] = label_map[key] + data_list.append(value) + + return data_list + +def main(input_file): + data = [] + with open(input_file, "r") as file: + data = tomlload(file.read()) + + items = convert(data) + + output_aux = input_file.removesuffix(".toml") + ".aux" + with open(output_aux, "w+") as file: + file.write(fmt_aux(items)) + + output_tex = input_file.removesuffix(".toml") + ".tex" + with open(output_tex, "w+") as file: + file.write(fmt_tex(items)) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: reqs2tex.py reqs.toml") + exit(1) + main(sys.argv[1]) + diff --git a/scripts/tex.py b/scripts/tex.py new file mode 100644 index 0000000..07d275a --- /dev/null +++ b/scripts/tex.py @@ -0,0 +1,81 @@ +# utility function for converting latex code + +def group(*args): + out = "" + for arg in args: + if isinstance(arg, list): + out += "[" + arg[0] + "]" + if isinstance(arg, str): + out += "{" + arg + "}" + return out + +def join(*things): + return "".join(things) + +def string(content): + return r"\string" + content + +def cmd(*args): + name = args[0] + args = args[1:] + if len(args) == 0: args = [""] + return f"\\{name}" + group(*args) + +def pedef(*args): + return r"\protected@edef" + cmd(*args) + +def csdef(*args): + return r"\def" + cmd(*args) + +def auxout(*content): + return r"\write\@auxout" + group(join(*content)) + +def scmd(*args): + return string(cmd(*args)) + +def env(name, *args): + content = args[-1] + args = args[0:-1] + out = f"\\begin{{{name}}}" + if len(args) > 0: + out += group(*args) + out += content + out += f"\\end{{{name}}}" + return out + +def esc(plain): + plain = plain.replace("\\", "\\string\\") + plain = plain.replace("#", "\\#") + plain = plain.replace("$", "\\$") + plain = plain.replace("%", "\\%") + return plain + +def tabrule(*cells): + return "&".join(cells) + "\\\\" + +def withatletter(*content): + return join( + cmd('makeatletter'), + *content, + cmd('makeatother'), + ) + +def explist(*items): + out = [] + for item in items: + if isinstance(item, str) or not hasattr(item, '__iter__'): + out.append(item) + else: + out += explist(*item) + return out + +def sec(level, heading): + level = max(min(3, level), 0) + section = [ + 'section', + 'subsection', + 'subsubsection', + 'paragraph', + ][level] + return cmd(section, heading) + diff --git a/scripts/time2tex.py b/scripts/time2tex.py new file mode 100755 index 0000000..a5d6802 --- /dev/null +++ b/scripts/time2tex.py @@ -0,0 +1,214 @@ +#!/bin/python3 +import sys, tex +from datetime import datetime, timedelta + +def fmt_duration(sec): + mins = (sec + 59) // 60 # integer divide, round up + out = [] + + if mins == 0: + return "--" + + hour = mins // 60 + if hour > 0: + out.append("%02dh" % (hour, )) + mins = mins % 60 + + out.append("%02dm" % (mins, )) + + return "\\,".join(out) + +def fmt_percentage(fac): + return tex.group( + tex.cmd('footnotesize') +\ + tex.cmd('itshape') +\ + tex.esc(f"({round(fac * 100)}%)") + ) + +def fmt_member_overview(times): + # calculations + tracked = {} + total_time = 0 + for time in times: + if not time["name"] in tracked: + tracked[time["name"]] = 0 + tracked[time["name"]] += time["duration"] + total_time += time["duration"] + + members = sorted(list(set(time["name"] for time in times))) + return tex.env('table', tex.join( + tex.cmd('centering'), + tex.env('tabular', 'lr@{~}l', tex.join( + tex.cmd('toprule'), + tex.tabrule(tex.cmd('textbf', 'Member'), tex.cmd('textbf', 'Tracked')), + tex.cmd('midrule'), + *[ + tex.tabrule( + name, + fmt_duration(tracked[name]), + fmt_percentage(tracked[name] / total_time)) + for name in members + ], + tex.cmd('midrule'), + tex.tabrule('', fmt_duration(total_time), ''), + tex.cmd('bottomrule'), + )), + tex.cmd('caption', 'Tracked time per group member'), + tex.cmd('label', 'tab:time-member'), + )) + +def fmt_weekly_overview(times): + # calculations + weeks = [] + member_totals = {} + total_time = sum(time["duration"] for time in times) + members = sorted(list(set(time["name"] for time in times))) + time_start = min(time["date"] for time in times) + time_end = max(time["date"] for time in times) + week_start = time_start - timedelta(days=time_start.weekday()) # round down to nearest monday + week_end = time_end + timedelta(days=7-time_end.weekday()) + + week = week_start + week_num = 1 + while week < week_end: + week_times = [time for time in times if time["date"] >= week and time["date"] < (week + timedelta(days=7))] + + week_entry = { + "num": week_num, + "members": {}, + "total": sum(time["duration"] for time in week_times) + } + + for member in members: + week_entry["members"][member] = sum(time["duration"] for time in week_times if time["name"] == member) + + weeks.append(week_entry) + week_num += 1 + week += timedelta(days=7) + for member in members: + member_totals[member] = sum(time["duration"] for time in times if time["name"] == member) + + return tex.env('table', tex.join( + tex.cmd('centering'), + tex.cmd('fitimg', + tex.env('tabular', r'l' + r'r@{~}l' * len(members) + r'@{\qquad}r', tex.join( + tex.cmd('toprule'), + tex.tabrule(*[ + tex.cmd('textbf', cell) + for cell in [ + tex.esc("#"), + *tex.explist([ member, "" ] for member in members), + "Subtotal", + ] + ]), + tex.cmd('midrule'), + *[ + tex.tabrule(*[ + str(entry['num']), + *tex.explist( + [ + fmt_duration(entry['members'][member]), + fmt_percentage(entry['members'][member] / entry['total']), + ] + for member in members + ), + fmt_duration(entry['total']), + ]) + for entry in weeks + ], + tex.cmd('bottomrule'), + )), + ), + tex.cmd('caption', 'Tracked time per week'), + tex.cmd('label', 'tab:time-weekly'), + )) + +def duration2secs(duration): + out = 0 # output (seconds) + cur = 0 # current figure (unknown) + for c in duration: + if c.isdigit(): + cur = cur * 10 + int(c) + continue + if c == "h": + out += cur * 3600 + cur = 0 + continue + if c == "m": + out += cur * 60 + cur = 0 + continue + if c == "s": + out += cur * 1 + cur = 0 + continue + + raise Exception("invalid duration format") + if cur != 0: raise Exception("invalid duration format") + return out + +def line2data(line): + # parse fields from input string + data = {} + next = line.find(':') + data["name"] = line[0:next].strip() + line = line[next+1:].strip() + next = line.find(' ') + data["date"] = line[0:next].strip() + line = line[next+1:].strip() + next = line.find(' ') + data["duration"] = line[0:next].strip() + line = line[next+1:].strip() + data["description"] = line + + # deserialize parsed fields + data["name"] = data["name"].title() + data["date"] = datetime.strptime(data["date"], '%Y-%m-%d') + data["duration"] = duration2secs(data["duration"]) + data["description"] = [el.strip() for el in data["description"].split("::")] + + return data + +def parse(content): + # split content at newlines + lines = content.split("\n") + out = [] + for i, line in enumerate(lines): + line = line.strip() + if line.startswith("#"): continue + if len(line) == 0: continue + + try: out.append(line2data(line)) + except Exception as e: raise Exception(f"line {i+1}: {e}") + return out + +def fmt(times): + return tex.join( + tex.cmd('section', 'Overviews'), + tex.cmd('subsection', 'Members'), + fmt_member_overview(times), + tex.cmd('subsection', 'Weekly'), + fmt_weekly_overview(times), + ) + +def main(input_file): + content = "" + with open(input_file, "r") as file: + content = file.read() + + try: parsed = parse(content) + except Exception as e: + print(f"{input_file}: {e}") + exit(1) + output = fmt(parsed) + + output_file = input_file.removesuffix(".txt") + ".tex" + with open(output_file, "w+") as file: + file.write(output) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: time2tex.py time.txt") + exit(1) + main(sys.argv[1]) + |