diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/.gitignore | 1 | ||||
| -rwxr-xr-x | scripts/reqs2tex.py | 110 | ||||
| -rw-r--r-- | scripts/tex.py | 52 | ||||
| -rwxr-xr-x | scripts/time2tex.py | 213 | 
4 files changed, 376 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..6b7b77a --- /dev/null +++ b/scripts/reqs2tex.py @@ -0,0 +1,110 @@ +#!/bin/python3 +import sys, tomllib, tex + +def flatten(data): +  if 'description' in data: +    return [ data ] +  out = [] +  for key, value in data.items(): +    items = flatten(value) +    for item in items: +      if 'label' in item: +        item['label'] = f"{key}.{item['label']}" +      else: +        item['label'] = f"{key}" +    out += items +  return out + +id_counter = 0 +def make_id(item): +  global id_counter +  id_counter += 1 +  return "{type_short}#{counter:03d}".format( +    type_short = item['type'][0].upper(), +    counter = id_counter, +  ) + +def sanitize(item, ids): +  def die(msg): +    print(f"[{item['label']}]: {msg}") +    exit(1) + +  # ensure properties +  item['description'] = item.get('description') +  item['done'] = item.get('done') +  item['priority'] = item.get('priority') +  item['type'] = item.get('type') + +  # type checks +  if item['type'] not in ['system', 'user']: +    die(f"unknown or missing requirement type {repr(item['type'])}") +  if item['priority'] not in ['must', 'should', 'could', 'will not']: +    die(f"unknown or missing requirement priority {repr(item['type'])}") + +  # conversions +  if isinstance(item['done'], list): +    # safety check +    if not set(item['done']).issubset(ids): +      die("definition of done includes unknown requirement(s)") +    item['done'] = tex.cmd('Cref', tex.label2ref(*item['done'])) + +def convert(data): +  reqs = flatten(data) +  index = 0 +  for item in reqs: +    item['id'] = tex.esc(make_id(item)) +    item['deleted'] = item.get('deleted', False) +    if item['deleted']: continue +    item['index'] = index +    index += 1 +    sanitize(item, [req['label'] for req in reqs]) + +  # skip deleted requirements (but process for make_id) +  reqs = [item for item in reqs if item['deleted'] == False] + +  return reqs + +def fmt_aux(data): +  out = [] +  for req in data: +    ref = tex.label2ref(req['label']) +    out += [ +      tex.cmd('newlabel', f"{ref}", tex.group(req['id'], req['id'], 'ggg', 'hhh', 'iii')), +      tex.cmd('newlabel', f"{ref}@cref", tex.group(f"[requirement][aaa][bbb]{req['id']}", '[ccc][ddd][eee]fff')), +    ] +  return "\n".join(out) + +def fmt_tex(data): +  out = [] +  for req in data: +    out.append( +      tex.cmd('subsection', req['id']) + "\n\n" +\ +      tex.env('description', +        tex.cmd('item', ['Priority']) + req['priority'].title() +\ +        tex.cmd('item', ['Requirement']) + req['description'] +\ +        (tex.cmd('item', ['Definition of done']) + req['done'] if req['done'] is not None else "") +      ) +    ) +  return "\n\n".join(out) + +def main(input_file): +  data = {} +  with open(input_file, "rb") as file: +    data = tomllib.load(file) + +  requirements = convert(data) + +  output_aux = input_file.removesuffix(".toml") + ".aux" +  with open(output_aux, "w+") as file: +    file.write(fmt_aux(requirements)) + +  output_tex = input_file.removesuffix(".toml") + ".tex" +  with open(output_tex, "w+") as file: +    file.write(fmt_tex(requirements)) + +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..2509a87 --- /dev/null +++ b/scripts/tex.py @@ -0,0 +1,52 @@ +# 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 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 csdef(*args): +  return r"\def" + cmd(*args) + +def auxout(content): +  return r"\write\@auxout" + group(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 label2ref(*labels): +  return ",".join(["req:" + label.replace('.', ':') for label in labels]) + diff --git a/scripts/time2tex.py b/scripts/time2tex.py new file mode 100755 index 0000000..8c3dd9b --- /dev/null +++ b/scripts/time2tex.py @@ -0,0 +1,213 @@ +#!/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"] + +  out = "" + +  # header +  out += tex.cmd('toprule') +  out += tex.tabrule(tex.cmd('textbf', 'Member'), tex.cmd('textbf', 'Tracked'), '') +  out += tex.cmd('midrule') + +  # member overview +  members = sorted(list(set(time["name"] for time in times))) +  for name in members: +    out += tex.tabrule(name, fmt_duration(tracked[name]), fmt_percentage(tracked[name] / total_time)) +  out += tex.cmd('midrule') + +  # sum +  out += tex.tabrule('', fmt_duration(total_time), '') +  out += tex.cmd('bottomrule') + +  out = tex.env('tabular', 'lr@{~}l', out) +  out = tex.cmd('centering') +\ +        out +\ +        tex.cmd('caption', 'Tracked time per group member') +\ +        tex.cmd('label', 'tab:time-member') +  out = tex.env('table', out) + +  return out + +def fmt_weekly_overview(times): +  # calculations +  out = "" +  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) + +  # TODO: refactor +  # begin table +  out += r"\begin{table}\centering" +  out += r"\fitimg{" +  out += f"\\begin{{tabular}}{{l{'r@{~}l' * len(members)}@{{\\qquad}}r}}\\toprule" +  out += r"\textbf{\#}" +  for member in members: +    out += f"&\\textbf{{{member}}}&" +  out += r"&\textbf{Subtotal}\\\midrule{}" + +  for entry in weeks: +    out += f"{entry['num']}" +    for member in members: +      out += f"&{fmt_duration(entry['members'][member])}&{fmt_percentage(entry['members'][member] / entry['total'])}" +    out += f"&{fmt_duration(entry['total'])}\\\\" + +  out += r"\midrule{}" +  for member in members: +    out += f"&{fmt_duration(member_totals[member])}&{fmt_percentage(member_totals[member] / total_time)}" +  out += f"&{fmt_duration(total_time)}\\\\" + +  # end table +  out += r"\bottomrule\end{tabular}" +  out += r"}" # \fitimg +  out += r"\caption{Tracked time per week}\label{tab:time-weekly}" +  out += r"\end{table}" + +  return out + +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 "\n\n".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]) + |