From c9d4ba22a8b6d0d5fb1062701eda8b1af554d422 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Sat, 14 Sep 2024 20:41:10 +0200 Subject: WIP requirements --- .gitignore | 1 + latexmkrc | 8 ++++++-- readme.md | 11 +++-------- reqs.toml | 31 +++++++++++++++++++++++++++++++ reqs2tex.py | 27 +++++++++++++++++++++++++++ requirements.tex | 11 +++++++++++ time2tex.py | 34 +++++++++++++++++++--------------- 7 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 reqs.toml create mode 100755 reqs2tex.py create mode 100644 requirements.tex diff --git a/.gitignore b/.gitignore index dfaa9e7..74caed0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ # generated files time.tex +reqs.tex diff --git a/latexmkrc b/latexmkrc index 293cf09..2ce20fa 100644 --- a/latexmkrc +++ b/latexmkrc @@ -14,7 +14,6 @@ $clean_ext .= ' %R.ist %R.xdy bbl run.xml'; push @file_not_found, '^Package .* No file `([^\\\']*)\\\''; push @generated_exts, 'glo', 'gls', 'glg'; - add_cus_dep('aux', 'glstex', 0, 'bib2gls'); sub bib2gls { return system "bib2gls '$_[0]'"; @@ -27,6 +26,11 @@ sub plantuml { add_cus_dep('txt', 'tex', 0, 'time2tex'); sub time2tex { - return system "./time2tex.py '$_[0].txt' > '$_[0].tex'"; + return system "python3 time2tex.py '$_[0].txt'"; +} + +add_cus_dep('toml', 'tex', 0, 'reqs2tex'); +sub reqs2tex { + return system "python3 reqs2tex.py '$_[0].toml'"; } diff --git a/readme.md b/readme.md index b63392b..818d445 100644 --- a/readme.md +++ b/readme.md @@ -9,20 +9,15 @@ Please see [style.md](./style.md) for writing style and Requirements: -- A LaTeX distribution that includes the XeLaTeX compiler +- A LaTeX distribution that includes the XeLaTeX compiler and latexmk - PlantUML -- Python 3 (timerep only) +- Python 3 - Fonts (see see [style.md](./style.md) for download links) -A `latexmkrc` file is provided for copmilation with `latexmk`. The documents +A `latexmkrc` file is provided for copmilation with latexmk. The documents should also compile under [Visual Studio Code][vscode] using the [LaTeX Workshop extension][latexworkshop], as well as [VimTeX][vimtex]. -## Requirements - -TODO: how to store + cross-reference requirements w/o extra latex compilation -runs - [vscode]: https://code.visualstudio.com [latexworkshop]: https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop [vimtex]: https://github.com/lervag/vimtex diff --git a/reqs.toml b/reqs.toml new file mode 100644 index 0000000..5e00dd9 --- /dev/null +++ b/reqs.toml @@ -0,0 +1,31 @@ +[audio.async-api] +type = 'system' +priority = 'must' +description = ''' +The public audio \gls{api} supports starting audio samples asynchronously +(i.e.~fire and forget). +''' + +[audio.handle] +type = 'system' +priority = 'must' +description = ''' +The public audio \gls{api} allows the game programmer to control (i.e.~play, +pause and stop) audio samples after they are created/initialized. +''' + +[audio.stream-mix] +type = 'system' +priority = 'must' +description = ''' +The audio system supports playing multiple audio streams simultaniously. +''' + +[aux.license] +type = 'system' +priority = 'must' +description = ''' +External libraries must have a license that is MIT-compatible, or one that +allows linking against MIT code. +''' + diff --git a/reqs2tex.py b/reqs2tex.py new file mode 100755 index 0000000..68c8f40 --- /dev/null +++ b/reqs2tex.py @@ -0,0 +1,27 @@ +#!/bin/python3 +import sys, tomllib + +def fmt(data): + print(data) + return f""" +\\makeatletter +\\makeatother +""" + +def main(input_file): + data = {} + with open(input_file, "rb") as file: + data = tomllib.load(file) + + output = fmt(data) + + output_file = input_file.removesuffix(".toml") + ".tex" + with open(output_file, "w+") as file: + file.write(output) + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: reqs2tex.py reqs.toml") + exit(1) + main(sys.argv[1]) + diff --git a/requirements.tex b/requirements.tex new file mode 100644 index 0000000..39e5831 --- /dev/null +++ b/requirements.tex @@ -0,0 +1,11 @@ +\documentclass{projdoc} +\input{meta.tex} +\input{reqs.tex} + +\title{Requirements} + +\begin{document} + + +\end{document} + diff --git a/time2tex.py b/time2tex.py index fe3091f..6e3de9c 100755 --- a/time2tex.py +++ b/time2tex.py @@ -24,12 +24,12 @@ def fmt_percentage(fac): def fmt_member_overview(times): # calculations out = "" - members = {} + tracked = {} total_time = 0 for time in times: - if not time["name"] in members: - members[time["name"]] = 0 - members[time["name"]] += time["duration"] + if not time["name"] in tracked: + tracked[time["name"]] = 0 + tracked[time["name"]] += time["duration"] total_time += time["duration"] # begin table @@ -38,8 +38,9 @@ def fmt_member_overview(times): out += r"\textbf{Member} & \textbf{Tracked} &\\\midrule{}" # member overview - for name, tracked in members.items(): - out += f"{name} & {fmt_duration(tracked)} & {fmt_percentage(tracked / total_time)}\\\\" + members = sorted(list(set(time["name"] for time in times))) + for name in members: + out += f"{name} & {fmt_duration(tracked[name])} & {fmt_percentage(tracked[name] / total_time)}\\\\" out += r"\midrule{}" # sum @@ -58,7 +59,7 @@ def fmt_weekly_overview(times): weeks = [] member_totals = {} total_time = sum(time["duration"] for time in times) - members = list(set(time["name"] 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 @@ -86,6 +87,7 @@ def fmt_weekly_overview(times): # 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: @@ -105,6 +107,7 @@ def fmt_weekly_overview(times): # 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}" @@ -170,17 +173,15 @@ def parse(content): return out def fmt(times): - # TODO: Task overview - print(f""" + return f""" \\section{{Overviews}}\n \\subsection{{Members}}\n {fmt_member_overview(times)} \\subsection{{Weekly}}\n {fmt_weekly_overview(times)} -""") +""" -def main(): - input_file = sys.argv[1] +def main(input_file): content = "" with open(input_file, "r") as file: content = file.read() @@ -189,12 +190,15 @@ def main(): except Exception as e: print(f"{input_file}: {e}") exit(1) + output = fmt(parsed) - 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 ") + print("usage: time2tex.py time.txt") exit(1) - main() + main(sys.argv[1]) -- cgit v1.2.3 From f6cb1e9d141d881ae6205027626d6643776e833c Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Sun, 15 Sep 2024 20:46:48 +0200 Subject: WIP requirements --- example.tex | 13 +++- latexmkrc | 5 +- projdoc.cls | 8 ++ reqs2tex.py | 27 ------- scripts/.gitignore | 1 + scripts/reqs2tex.py | 83 ++++++++++++++++++++ scripts/tex.py | 43 +++++++++++ scripts/time2tex.py | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++++ time2tex.py | 204 ------------------------------------------------- 9 files changed, 362 insertions(+), 235 deletions(-) delete mode 100755 reqs2tex.py create mode 100644 scripts/.gitignore create mode 100755 scripts/reqs2tex.py create mode 100644 scripts/tex.py create mode 100755 scripts/time2tex.py delete mode 100755 time2tex.py diff --git a/example.tex b/example.tex index 4ae4d95..ee1377a 100644 --- a/example.tex +++ b/example.tex @@ -3,6 +3,8 @@ % with the [draft] option. this replaces all images with placeholders. \input{meta.tex} +\input{reqs.aux} + \title{Example Document} \begin{document} @@ -155,17 +157,24 @@ Description: \con{Bad thing 2} \end{comparison} -\subsection{Citations} +\subsection{References} + +\subsubsection{Citations} Citations are inserted using the \codeinline{\autocite} command \autocite{rfc:3339}. The bibliography is automatically printed after \codeinline{\end{document}}. -\subsection{Glossary} +\subsubsection{Glossary} Glossary entries can be inserted using the \codeinline{\gls} commands. Example: ``\Gls{sdl2} handles \glspl{hid} as well!''. In following occurrences of acronyms, only their short form is printed: `\gls{sdl2}' and `\gls{hid}'. All of these link to the glossary that is automatically printed after \codeinline{\end{document}}. +\subsubsection{Requirements} + +Requirements are referenced like \codeinline{\label}s: +e.g.~\cref{req:audio:handle:id,req:audio:async-api:id}. + \end{document} diff --git a/latexmkrc b/latexmkrc index 2ce20fa..5a49064 100644 --- a/latexmkrc +++ b/latexmkrc @@ -26,11 +26,12 @@ sub plantuml { add_cus_dep('txt', 'tex', 0, 'time2tex'); sub time2tex { - return system "python3 time2tex.py '$_[0].txt'"; + return system "python3 scripts/time2tex.py '$_[0].txt'"; } add_cus_dep('toml', 'tex', 0, 'reqs2tex'); +add_cus_dep('toml', 'aux', 0, 'reqs2tex'); sub reqs2tex { - return system "python3 reqs2tex.py '$_[0].toml'"; + return system "python3 scripts/reqs2tex.py '$_[0].toml'"; } diff --git a/projdoc.cls b/projdoc.cls index 0d27a1f..c11fe61 100644 --- a/projdoc.cls +++ b/projdoc.cls @@ -307,3 +307,11 @@ \newcommand\noparbreak{\par\nobreak\@afterheading} \makeatother +% cleveref extra types +\crefname{paragraph}{paragraph}{paragraphs} +\Crefname{paragraph}{Paragraph}{Paragraphs} +\crefname{requirement}{requirement}{requirements} +\Crefname{requirement}{Requirement}{Requirements} +\crefname{test}{test}{tests} +\Crefname{test}{Test}{Tests} + diff --git a/reqs2tex.py b/reqs2tex.py deleted file mode 100755 index 68c8f40..0000000 --- a/reqs2tex.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/python3 -import sys, tomllib - -def fmt(data): - print(data) - return f""" -\\makeatletter -\\makeatother -""" - -def main(input_file): - data = {} - with open(input_file, "rb") as file: - data = tomllib.load(file) - - output = fmt(data) - - output_file = input_file.removesuffix(".toml") + ".tex" - with open(output_file, "w+") as file: - file.write(output) - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("usage: reqs2tex.py reqs.toml") - exit(1) - main(sys.argv[1]) - 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]) + diff --git a/time2tex.py b/time2tex.py deleted file mode 100755 index 6e3de9c..0000000 --- a/time2tex.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/bin/python3 -import sys -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 f"{{\\footnotesize\\itshape({round(fac * 100)}\\%)}}" - -def fmt_member_overview(times): - # calculations - out = "" - 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"] - - # begin table - out += r"\begin{table}\centering" - out += r"\begin{tabular}{lr@{~}l}\toprule" - out += r"\textbf{Member} & \textbf{Tracked} &\\\midrule{}" - - # member overview - members = sorted(list(set(time["name"] for time in times))) - for name in members: - out += f"{name} & {fmt_duration(tracked[name])} & {fmt_percentage(tracked[name] / total_time)}\\\\" - out += r"\midrule{}" - - # sum - out += f"&{fmt_duration(total_time)}&\\\\" - - # end table - out += r"\bottomrule\end{tabular}" - out += r"\caption{Tracked time per group member}\label{tab:time-member}" - out += r"\end{table}" - - 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) - - # 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 f""" -\\section{{Overviews}}\n -\\subsection{{Members}}\n -{fmt_member_overview(times)} -\\subsection{{Weekly}}\n -{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 From dd2db2b7f62106e6c6c2abdaed73c5f608c690c6 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Mon, 16 Sep 2024 16:40:46 +0200 Subject: update reqs2tex and add comments to reqs.toml --- reqs.toml | 25 +++++++++++++++++++++++++ scripts/reqs2tex.py | 42 +++++++++++++++++++++++++++++++++++------- scripts/tex.py | 3 +++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/reqs.toml b/reqs.toml index 5e00dd9..c05cf71 100644 --- a/reqs.toml +++ b/reqs.toml @@ -1,10 +1,35 @@ +# This is a TOML file containing all project requirements. The reqs2tex script +# can be used to generate the files necessary to compile requirements.tex and +# cross-reference the requirements from other documents. +# +# Note that TOML has a hash table structure, so keys used for defining +# requirement properties cannot be used for requirement IDs. The properties are: +# label, type, id, index, deleted, done, description, priority + +# This is the requirement cross-reference ID. Requirements can be +# (cross-)referenced from LaTeX by prefixing this ID with `req:` and +# substituting dots for colons (i.e. this requirement is referenced as +# \cref{req:audio:async-api}). [audio.async-api] +# Requirement type ('system' | 'user') type = 'system' +# MoSCoW priority ('must' | 'should' | 'could' | 'will not') priority = 'must' +# Requirement body. Supports LaTeX formatting. (tip: use single quotes so +# backslash doesn't act as an escape character) description = ''' The public audio \gls{api} supports starting audio samples asynchronously (i.e.~fire and forget). ''' +# Definition of done (user requirements only). If 'done' is a string, it is +# treated as LaTeX code (like description), if it is a list of strings, each +# item is treated as the ID of another requirement. +#done = 'When I feel like it' +#done = [ 'audio.handle', 'audio.stream-mix' ] +# Requirements that are no longer applicable should set `deleted` to `true`. +# This will make sure the requirements are numbered consistently across +# different document revisions. +#deleted = true [audio.handle] type = 'system' diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py index 9e71a48..c5ab3dd 100755 --- a/scripts/reqs2tex.py +++ b/scripts/reqs2tex.py @@ -9,7 +9,7 @@ def flatten(data): items = flatten(value) for item in items: if 'label' in item: - item['label'] = f"{key}:{item['label']}" + item['label'] = f"{key}.{item['label']}" else: item['label'] = f"{key}" out += items @@ -24,16 +24,44 @@ def make_id(item): 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'])}") + + # logic checks + if item['type'] != 'user' and item['done'] is not None: + die("has definition of done but is not a user requirement") + + # 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) - for index, item in enumerate(reqs): + index = 0 + for item in 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) + 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] diff --git a/scripts/tex.py b/scripts/tex.py index b044857..2fd51d8 100644 --- a/scripts/tex.py +++ b/scripts/tex.py @@ -41,3 +41,6 @@ def esc(plain): def tabrule(*cells): return "&".join(cells) + "\\\\" +def label2ref(*labels): + return ",".join(["req:" + label.replace('.', ':') for label in labels]) + -- cgit v1.2.3 From b31ebef3db3765eef8e0492897e870a9fa4cd32b Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Mon, 16 Sep 2024 18:48:23 +0200 Subject: fix cross-reference links to external file --- example.tex | 4 ++-- projdoc.cls | 1 + readme.md | 6 ++++++ requirements.tex | 1 - scripts/reqs2tex.py | 13 +++++-------- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/example.tex b/example.tex index ee1377a..e0d21c0 100644 --- a/example.tex +++ b/example.tex @@ -3,7 +3,7 @@ % with the [draft] option. this replaces all images with placeholders. \input{meta.tex} -\input{reqs.aux} +\externaldocument{reqs}[./requirements.pdf] \title{Example Document} @@ -174,7 +174,7 @@ the glossary that is automatically printed after \codeinline{\end{document}}. \subsubsection{Requirements} Requirements are referenced like \codeinline{\label}s: -e.g.~\cref{req:audio:handle:id,req:audio:async-api:id}. +e.g.~\cref{req:audio:handle,req:audio:async-api}. \end{document} diff --git a/projdoc.cls b/projdoc.cls index c11fe61..8a592e3 100644 --- a/projdoc.cls +++ b/projdoc.cls @@ -40,6 +40,7 @@ \RequirePackage{tabularx} \RequirePackage{booktabs} \RequirePackage{needspace} +\RequirePackage{xr-hyper} \RequirePackage{hyperref} \RequirePackage{microtype} \RequirePackage{xcolor} diff --git a/readme.md b/readme.md index 818d445..7b58cfd 100644 --- a/readme.md +++ b/readme.md @@ -18,6 +18,12 @@ A `latexmkrc` file is provided for copmilation with latexmk. The documents should also compile under [Visual Studio Code][vscode] using the [LaTeX Workshop extension][latexworkshop], as well as [VimTeX][vimtex]. +## TODO + +- Requirement cross-references are broken (they print both the label and the + path to the other document, should be label only). Interesting: + `\creflabelformat` and `\@templabel` (inside #2 of `\creflabelformat`). + [vscode]: https://code.visualstudio.com [latexworkshop]: https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop [vimtex]: https://github.com/lervag/vimtex diff --git a/requirements.tex b/requirements.tex index 39e5831..1b51220 100644 --- a/requirements.tex +++ b/requirements.tex @@ -6,6 +6,5 @@ \begin{document} - \end{document} diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py index c5ab3dd..667eeb6 100755 --- a/scripts/reqs2tex.py +++ b/scripts/reqs2tex.py @@ -69,18 +69,15 @@ def convert(data): return reqs def req2aux(req): - # TODO: this is a dead-end solution, newlabel only works for in-document anchors, not external links + ref = tex.label2ref(req['label']) 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']}", '')), + 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([tex.auxout(line) for line in out]) + return "\n".join(out) def fmt_aux(data): - out = "" - out += tex.cmd('makeatletter') - out += "\n".join([req2aux(req) for req in data]) - out += tex.cmd('makeatother') + out = "\n".join([req2aux(req) for req in data]) return out def fmt_tex(data): -- cgit v1.2.3 From 2e49e0e0db184295eb08e930a3ccdf10e80e40fe Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Mon, 16 Sep 2024 19:07:50 +0200 Subject: implement simple requirements dump --- glossary.bib | 5 +++++ projdoc.cls | 25 +++++++++++++++---------- requirements.tex | 12 +++++++++++- scripts/reqs2tex.py | 32 +++++++++++++++++++------------- scripts/tex.py | 8 +++++++- 5 files changed, 57 insertions(+), 25 deletions(-) diff --git a/glossary.bib b/glossary.bib index 4d02e4a..437db86 100644 --- a/glossary.bib +++ b/glossary.bib @@ -28,3 +28,8 @@ description = {Graphics library developed by \hbox{Microsoft}}, } +@acronym{api, + short = {API}, + long = {Application Programming Interface}, +} + diff --git a/projdoc.cls b/projdoc.cls index 8a592e3..fe8c8bc 100644 --- a/projdoc.cls +++ b/projdoc.cls @@ -121,16 +121,23 @@ itemsep=\dimexpr\style@itemsep-\style@parsep\relax, parsep=\style@parsep, } -\def\projdoc@setdescriptionstyle{% +\def\projdoc@description@before{% \renewcommand\makelabel[1]{% {\bfseries ##1}:% }% } -\setdescription{ - before={\projdoc@setdescriptionstyle}, - leftmargin=3em, - labelindent=3ex, +\newlength\projdoc@description@leftmargin% +\projdoc@description@leftmargin=3em% +\newlength\projdoc@description@labelindent% +\projdoc@description@labelindent=3ex% +\def\projdoc@setdescriptionstyle{% + \setdescription{ + before={\projdoc@description@before}, + leftmargin=\projdoc@description@leftmargin, + labelindent=\projdoc@description@labelindent, + }% } +\projdoc@setdescriptionstyle% \makeatother % create a label using \customlabel[]{}{} that displays @@ -230,11 +237,9 @@ }{}% % glossary \ifbool{projdoc@used@gls}{% - \setdescription{ - before={\projdoc@setdescriptionstyle}, - leftmargin=2ex, - labelindent=0pt, - }% + \projdoc@description@leftmargin=2ex% + \projdoc@description@labelindent=0pt% + \projdoc@setdescriptionstyle% \section*{Glossary}% \begin{multicols}{2}% \renewcommand{\glossarysection}[2][]{}% diff --git a/requirements.tex b/requirements.tex index 1b51220..dee529d 100644 --- a/requirements.tex +++ b/requirements.tex @@ -1,10 +1,20 @@ \documentclass{projdoc} \input{meta.tex} -\input{reqs.tex} + +\makeatletter +\projdoc@description@leftmargin=2ex +\projdoc@description@labelindent=0pt +\projdoc@setdescriptionstyle +\makeatother \title{Requirements} \begin{document} +\section{Requirements} +\begin{multicols}{2} +\input{reqs.tex} +\end{multicols} + \end{document} diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py index 667eeb6..3bf0501 100755 --- a/scripts/reqs2tex.py +++ b/scripts/reqs2tex.py @@ -68,22 +68,28 @@ def convert(data): return reqs -def req2aux(req): - 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_aux(data): - out = "\n".join([req2aux(req) for req in data]) - return out + 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): - return "\n".join([ - tex.cmd('relax') - ]) + 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['type'] == 'user' else "") + ) + ) + return "\n\n".join(out) def main(input_file): data = {} diff --git a/scripts/tex.py b/scripts/tex.py index 2fd51d8..2509a87 100644 --- a/scripts/tex.py +++ b/scripts/tex.py @@ -1,7 +1,13 @@ # utility function for converting latex code def group(*args): - return "".join("{" + arg + "}" for arg in 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 -- cgit v1.2.3 From 976a8d260fd138161317aac14df5311ef0ef07ca Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Mon, 16 Sep 2024 19:08:37 +0200 Subject: add table of contents to requirements --- requirements.tex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.tex b/requirements.tex index dee529d..2936272 100644 --- a/requirements.tex +++ b/requirements.tex @@ -10,6 +10,8 @@ \title{Requirements} \begin{document} +\tablestables +\newpage \section{Requirements} \begin{multicols}{2} -- cgit v1.2.3 From 1de74261bef1a4c25250b7390b965093141c88f0 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 10:12:20 +0200 Subject: remove user requirement dod check --- scripts/reqs2tex.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py index 3bf0501..6b7b77a 100755 --- a/scripts/reqs2tex.py +++ b/scripts/reqs2tex.py @@ -41,10 +41,6 @@ def sanitize(item, ids): if item['priority'] not in ['must', 'should', 'could', 'will not']: die(f"unknown or missing requirement priority {repr(item['type'])}") - # logic checks - if item['type'] != 'user' and item['done'] is not None: - die("has definition of done but is not a user requirement") - # conversions if isinstance(item['done'], list): # safety check @@ -86,7 +82,7 @@ def fmt_tex(data): 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['type'] == 'user' else "") + (tex.cmd('item', ['Definition of done']) + req['done'] if req['done'] is not None else "") ) ) return "\n\n".join(out) -- cgit v1.2.3 From 8a1a98b8515baa0b7cb24ba2d4932066b72b89a5 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 10:17:21 +0200 Subject: fix time2tex --- scripts/time2tex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/time2tex.py b/scripts/time2tex.py index 17661d5..8c3dd9b 100755 --- a/scripts/time2tex.py +++ b/scripts/time2tex.py @@ -19,7 +19,7 @@ def fmt_duration(sec): return "\\,".join(out) def fmt_percentage(fac): - return tex.sgroup( + return tex.group( tex.cmd('footnotesize') +\ tex.cmd('itshape') +\ tex.esc(f"({round(fac * 100)}%)") -- cgit v1.2.3 From baab5893578210623bc2fc391c2423fda38b8e65 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 10:29:36 +0200 Subject: update time.txt --- time.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/time.txt b/time.txt index 9f2c398..34959d3 100644 --- a/time.txt +++ b/time.txt @@ -28,6 +28,7 @@ loek: 2024-09-14 45m refactoring :: clean up LaTeX build system loek: 2024-09-14 1h10m docs :: requirements loek: 2024-09-15 2h55m docs :: requirements loek: 2024-09-16 2h30m docs :: requirements +loek: 2024-09-17 1h20m project meeting max: 2024-09-02 1h project kickoff max: 2024-09-02 45m first project meeting -- cgit v1.2.3 From 26c860fc5018b20aac301ebc297db6ed3887b967 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 12:30:31 +0200 Subject: fix file spec on Windows --- latexmkrc | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/latexmkrc b/latexmkrc index 5a49064..907c5a5 100644 --- a/latexmkrc +++ b/latexmkrc @@ -1,5 +1,7 @@ # https://nl.mirrors.cicku.me/ctan/support/latexmk/latexmk.pdf +use File::Spec::Functions; + $pdflatex = "xelatex --interaction=nonstopmode %O %S"; $pdf_mode = 1; $dvi_mode = 0; @@ -26,12 +28,16 @@ sub plantuml { add_cus_dep('txt', 'tex', 0, 'time2tex'); sub time2tex { - return system "python3 scripts/time2tex.py '$_[0].txt'"; + my $script = catfile("scripts", "time2tex.py"); + return system "python3 $script '$_[0].txt'"; } add_cus_dep('toml', 'tex', 0, 'reqs2tex'); add_cus_dep('toml', 'aux', 0, 'reqs2tex'); sub reqs2tex { - return system "python3 scripts/reqs2tex.py '$_[0].toml'"; + my $script = catfile("scripts", "reqs2tex.py"); + return system "python3 $script '$_[0].toml'"; } +# vim:ft=perl + -- cgit v1.2.3 From c38b4833f33d7bb3510962fe3e8c6c9b194b7542 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 12:31:50 +0200 Subject: update time.txt --- time.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/time.txt b/time.txt index 34959d3..16c0523 100644 --- a/time.txt +++ b/time.txt @@ -29,6 +29,7 @@ loek: 2024-09-14 1h10m docs :: requirements loek: 2024-09-15 2h55m docs :: requirements loek: 2024-09-16 2h30m docs :: requirements loek: 2024-09-17 1h20m project meeting +loek: 2024-09-17 15m bugs :: cross-platform latexmk filespec max: 2024-09-02 1h project kickoff max: 2024-09-02 45m first project meeting -- cgit v1.2.3 From 5aba97da07413c498871c5da4ce8d0f2427ffc69 Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 14:59:17 +0200 Subject: fix latexmkrc --- latexmkrc | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/latexmkrc b/latexmkrc index 907c5a5..880f859 100644 --- a/latexmkrc +++ b/latexmkrc @@ -18,25 +18,23 @@ push @file_not_found, '^Package .* No file `([^\\\']*)\\\''; push @generated_exts, 'glo', 'gls', 'glg'; add_cus_dep('aux', 'glstex', 0, 'bib2gls'); sub bib2gls { - return system "bib2gls '$_[0]'"; + return Run_msg("bib2gls $_[0]"); } add_cus_dep('puml', 'eps', 0, 'plantuml'); sub plantuml { - return system "plantuml -teps '$_[0].puml'"; + return Run_msg("plantuml -teps $_[0].puml"); } add_cus_dep('txt', 'tex', 0, 'time2tex'); sub time2tex { - my $script = catfile("scripts", "time2tex.py"); - return system "python3 $script '$_[0].txt'"; + return Run_msg("python3 @{[catfile('scripts', 'time2tex.py')]} $_[0].txt"); } add_cus_dep('toml', 'tex', 0, 'reqs2tex'); add_cus_dep('toml', 'aux', 0, 'reqs2tex'); sub reqs2tex { - my $script = catfile("scripts", "reqs2tex.py"); - return system "python3 $script '$_[0].toml'"; + return Run_msg("python3 @{[catfile('scripts', 'reqs2tex.py')]} $_[0].toml"); } # vim:ft=perl -- cgit v1.2.3 From 69bb15bd19cd62b39e2af8a2ab890b1ce25f514c Mon Sep 17 00:00:00 2001 From: Loek Le Blansch Date: Tue, 17 Sep 2024 15:04:35 +0200 Subject: update time.txt --- time.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time.txt b/time.txt index 16c0523..94058da 100644 --- a/time.txt +++ b/time.txt @@ -29,7 +29,7 @@ loek: 2024-09-14 1h10m docs :: requirements loek: 2024-09-15 2h55m docs :: requirements loek: 2024-09-16 2h30m docs :: requirements loek: 2024-09-17 1h20m project meeting -loek: 2024-09-17 15m bugs :: cross-platform latexmk filespec +loek: 2024-09-17 55m bugs :: cross-platform latexmk filespec max: 2024-09-02 1h project kickoff max: 2024-09-02 45m first project meeting -- cgit v1.2.3