diff --git a/formatscaper/Pipfile b/formatscaper/Pipfile index af383c7724a4ebb64185206477d01dafdd4234c9..bda311cdc4bc724bb591ef9f11ba6791d25a8456 100644 --- a/formatscaper/Pipfile +++ b/formatscaper/Pipfile @@ -6,6 +6,7 @@ name = "pypi" [packages] pyyaml = "*" progressbar2 = "*" +urwid = "*" [dev-packages] diff --git a/formatscaper/Pipfile.lock b/formatscaper/Pipfile.lock index a8cc6aec2ebda9ee7790adc8c9bfa7a90d60ee5e..24de05088d0203a505fb8c4505dcff0e775b2af0 100644 --- a/formatscaper/Pipfile.lock +++ b/formatscaper/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f17599e0484b4226bebde0b6b444e0c48dcac21c3258f6354ef585800fdd2ab4" + "sha256": "a5e084bb30ee94af6dce803be60cad5d735281bfaebfb46d70a85380b0e4c130" }, "pipfile-spec": 6, "requires": { @@ -27,11 +27,11 @@ }, "python-utils": { "hashes": [ - "sha256:ec3a672465efb6c673845a43afcfafaa23d2594c24324a40ec18a0c59478dc0b", - "sha256:efdf31c8154667d7dc0317547c8e6d3b506c5d4b6e360e0c89662306262fc0ab" + "sha256:ad0ccdbd6f856d015cace07f74828b9840b5c4072d9e868a7f6a14fd195555a8", + "sha256:c5d161e4ca58ce3f8c540f035e018850b261a41e7cb98f6ccf8e1deb7174a1f1" ], "markers": "python_version >= '3.9'", - "version": "==3.8.1" + "version": "==3.8.2" }, "pyyaml": { "hashes": [ @@ -64,6 +64,7 @@ "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", @@ -97,6 +98,22 @@ ], "markers": "python_version >= '3.8'", "version": "==4.9.0" + }, + "urwid": { + "hashes": [ + "sha256:bc302170fdbdda0aded2787ba66006af939dcff967606e9840a6f2af149adf12", + "sha256:dea5b2e8f992946774fe864894d55c1045175d96c1266904755909dcd52d3cb5" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.6.4" + }, + "wcwidth": { + "hashes": [ + "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", + "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" + ], + "version": "==0.2.13" } }, "develop": {} diff --git a/formatscaper/resultman.py b/formatscaper/resultman.py new file mode 100755 index 0000000000000000000000000000000000000000..c816e62379db60b76a7930ac5cd20d4c89ee84fb --- /dev/null +++ b/formatscaper/resultman.py @@ -0,0 +1,248 @@ +#!/bin/env python3 + +import argparse +import enum + +import urwid as uw +from urwid.command_map import Command + +from core.utils import Format, load_formats, load_results + + +# helper functions +def fallback_key_handler(key: str) -> None: + """Handle keys that haven't been handled by any widgets.""" + if key == "Q": + raise uw.ExitMainLoop() + + elif key in ["q", "h", "esc"]: + # ideally these keys would be handled by the right side when it's focused + # as this action only makes sense then + columns.set_focus(left) + + +class FormatFilter(enum.Enum): + """Filter settings for file formats.""" + + ALL = "all" + SAFE = "safe" + ENDANGERED = "endangered" + + +class SText(uw.Text): + """A selectable Text widget.""" + + _selectable = True + + def keypress(self, size, key): + """Don't do anything on keypress.""" + return key + + +class SimpleButton(uw.Button): + """Button widget with simpler decoration.""" + + button_left = uw.Text("*") + button_right = uw.Text(" ") + + +# parsing CLI arguments +parser = argparse.ArgumentParser( + description="TUI tool for managing file format information" +) +parser.add_argument( + "--formats", + "-f", + default="formats.yaml", + help="formats file", +) +parser.add_argument( + "--results", + "-r", + default="results.pickle", + help="results file", +) +parser.add_argument( + "--filter", + default=FormatFilter.ALL.value, + choices=[f.value for f in FormatFilter], + help="filter for formats based on their risk", +) +parser.add_argument( + "--invenio-domain", + default="researchdata.tuwien.ac.at", + help="domain name of the repository for building links", +) +args = parser.parse_args() +current_filter = FormatFilter(args.filter) + + +# loading formats & results +formats, all_results = [], [] +try: + formats = list(load_formats(args.formats).values()) +except Exception as e: + print(e) + +try: + all_results = load_results(args.results) or [] + all_results_per_record = { + rec: [res for res in all_results if res.record == rec] + for rec in {res.record for res in all_results} + } +except Exception as e: + all_results, all_results_per_record = [], {} + print(e) + + +# color palette & settings +palette = [ + ("border", "light gray,bold", "dark gray"), + ("bold", "bold", ""), + ("darkbg", "", "black"), + ("reversed", "bold,standout", ""), + ("endangered", "white", "dark red"), +] + +uw.command_map["j"] = Command.DOWN +uw.command_map["k"] = Command.UP +uw.command_map["ctrl d"] = Command.PAGE_DOWN +uw.command_map["ctrl u"] = Command.PAGE_UP + +PRONOM_BASE_URL = "https://www.nationalarchives.gov.uk/PRONOM/%(puid)s" +REPOSITORY_BASE_URL = f"https://{args.invenio_domain}/records/%(recid)s" + + +# event handlers +def handle_select_format(format: Format, button: uw.Button): + """Set up the format details side (right) based on the selected format.""" + relevant_results = { + rec: sorted(ress, key=lambda r: r.filename) + for rec, ress in { + rec: [r for r in ress if r.format == format] + for rec, ress in all_results_per_record.items() + }.items() + if ress + } + + content = [] + i, num_files = 0, 0 + for rec, results in relevant_results.items(): + i += 1 + repository_url = REPOSITORY_BASE_URL % {"recid": rec} + content.append(uw.Filler(uw.Text([f" {i}) ", ("bold", repository_url)]))) + + for res in results: + content.append( + uw.AttrMap( + SText([" * ", res.filename], wrap="any"), + None, + focus_map="reversed", + ) + ) + content.append(uw.Divider()) + num_files += len(results) + + bottom = uw.SolidFill(".") + if relevant_results: + _files = "file" if num_files == 1 else "files" + _records = "record" if len(relevant_results) == 1 else "records" + bottom = uw.ScrollBar( + uw.ListBox( + uw.SimpleFocusListWalker( + [ + uw.Divider(), + uw.AttrMap( + uw.Text( + f" {num_files} {_files} in " + f"{len(relevant_results)} {_records}:" + ), + None, + focus_map="reversed", + ), + uw.Divider(), + *content, + ] + ) + ) + ) + + pronom_url = PRONOM_BASE_URL % {"puid": format.puid} + format_header = uw.Pile( + [ + uw.Filler(uw.Divider()), + uw.Filler(uw.Text([" Name: ", format.name])), + uw.Filler(uw.Text([" MIME: ", format.mime or "-"])), + uw.Filler(uw.Text([" PRONOM: ", pronom_url])), + uw.Filler(uw.Divider()), + ] + ) + + format_details = uw.Pile( + [ + ("pack", format_header), + ("pack", uw.AttrMap(uw.Filler(uw.Divider()), "border")), + bottom, + ] + ) + + right.contents.pop() + right.contents.append((format_details, ("weight", 1))) + + # if the details side has actual content to display, focus it + # columns: contains the format list (left) and the format details (right) + # right: has the format info header, divider, and files list + if relevant_results: + columns.set_focus(right) + right.set_focus(format_details) + + +# defining the basic layout +def create_format_buttons(filter: FormatFilter): + format_buttons = [] + for format in sorted((f for f in formats if f.name), key=lambda f: f.name): + if filter == FormatFilter.ENDANGERED and not format.endangered: + continue + elif filter == FormatFilter.SAFE and format.endangered: + continue + + attribute = "endangered" if format.endangered else "darkbg" + button = SimpleButton(format.name) + uw.connect_signal(button, "click", handle_select_format, user_args=[format]) + format_buttons.append(uw.AttrMap(button, attribute, focus_map="reversed")) + + return format_buttons + + +formats = uw.ScrollBar( + uw.ListBox(uw.SimpleFocusListWalker(create_format_buttons(current_filter))) +) +formats._command_map["l"] = "activate" +formats_label = uw.Filler(uw.AttrMap(uw.Text("FORMATS", align="center"), "border")) +left = uw.Pile([("pack", formats_label), formats]) + +help_text = """ +Formatscaper results manager help + +Up/Down/j/k/^d/^u: navigate up and down +Enter/Space/l: select current format +Esc/h/q: go back to formats list +Q: quit +""" +details = uw.AttrMap(uw.Filler(uw.Text(help_text, align="center")), "darkbg") +details_label = uw.Filler(uw.AttrMap(uw.Text("DETAILS", align="center"), "border")) +right = uw.Pile([("pack", details_label), details]) + +div = uw.AttrMap(uw.SolidFill(" "), "border") +columns = uw.Columns([("weight", 1, left), (1, div), ("weight", 2, right)]) + +filter_info = uw.Text(f"Showing: {current_filter.value} formats") +status_line = uw.Filler(uw.AttrMap(filter_info, "border")) +top = uw.Pile([uw.AttrMap(columns, "darkbg"), ("pack", status_line)]) + +loop = uw.MainLoop(top, palette, unhandled_input=fallback_key_handler) + +try: + loop.run() +except KeyboardInterrupt: + loop.stop()