From f6cb1e9d141d881ae6205027626d6643776e833c Mon Sep 17 00:00:00 2001
From: Loek Le Blansch <loek@pipeframe.xyz>
Date: Sun, 15 Sep 2024 20:46:48 +0200
Subject: WIP requirements

---
 scripts/.gitignore  |   1 +
 scripts/reqs2tex.py |  83 ++++++++++++++++++++
 scripts/tex.py      |  43 +++++++++++
 scripts/time2tex.py | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 340 insertions(+)
 create mode 100644 scripts/.gitignore
 create mode 100755 scripts/reqs2tex.py
 create mode 100644 scripts/tex.py
 create mode 100755 scripts/time2tex.py

(limited to 'scripts')

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..9e71a48
--- /dev/null
+++ b/scripts/reqs2tex.py
@@ -0,0 +1,83 @@
+#!/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 convert(data):
+  reqs = flatten(data)
+  for index, item in enumerate(reqs):
+    item['id'] = tex.esc(make_id(item))
+    item['index'] = index
+    item['description'] = item.get('description', '???')
+    item['done'] = item.get('done', None)
+    item['priority'] = item.get('priority', 'must')
+    item['type'] = item.get('type', 'system')
+    item['deleted'] = item.get('deleted', False)
+
+  # skip deleted requirements (but process for make_id)
+  reqs = [item for item in reqs if item['deleted'] == False]
+
+  return reqs
+
+def req2aux(req):
+  # TODO: this is a dead-end solution, newlabel only works for in-document anchors, not external links
+  out = [
+    tex.scmd('newlabel', f"req:{req['label']}:id", tex.group(req['id'], req['id'], '', './requirements.pdf', '')),
+    tex.scmd('newlabel', f"req:{req['label']}:id@cref", tex.group(f"[requirement][][]{req['id']}", '')),
+  ]
+  return "\n".join([tex.auxout(line) for line in out])
+
+def fmt_aux(data):
+  out = ""
+  out += tex.cmd('makeatletter')
+  out += "\n".join([req2aux(req) for req in data])
+  out += tex.cmd('makeatother')
+  return out
+
+def fmt_tex(data):
+  return "\n".join([
+    tex.cmd('relax')
+  ])
+
+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..b044857
--- /dev/null
+++ b/scripts/tex.py
@@ -0,0 +1,43 @@
+# utility function for converting latex code
+
+def group(*args):
+  return "".join("{" + arg + "}" for arg in args)
+
+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) + "\\\\"
+
diff --git a/scripts/time2tex.py b/scripts/time2tex.py
new file mode 100755
index 0000000..17661d5
--- /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.sgroup(
+    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])
+
-- 
cgit v1.2.3