aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/.gitignore1
-rwxr-xr-xscripts/reqs2tex.py173
-rw-r--r--scripts/tex.py81
-rwxr-xr-xscripts/time2tex.py214
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])
+