From eebd5d8c6d8a756384b6cf1b2cc76b8a83cdf929 Mon Sep 17 00:00:00 2001 From: Mia Herkt Date: Tue, 20 Dec 2022 15:45:55 +0100 Subject: Add moderation TUI This ended up way fancier than I imagined. --- modui/__init__.py | 3 ++ modui/filetable.py | 72 +++++++++++++++++++++++++++++ modui/mime.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++ modui/mpvwidget.py | 88 ++++++++++++++++++++++++++++++++++++ modui/notification.py | 8 ++++ 5 files changed, 293 insertions(+) create mode 100644 modui/__init__.py create mode 100644 modui/filetable.py create mode 100644 modui/mime.py create mode 100644 modui/mpvwidget.py create mode 100644 modui/notification.py (limited to 'modui') diff --git a/modui/__init__.py b/modui/__init__.py new file mode 100644 index 0000000..2e190ea --- /dev/null +++ b/modui/__init__.py @@ -0,0 +1,3 @@ +from .filetable import FileTable +from .notification import Notification +from .mpvwidget import MpvWidget diff --git a/modui/filetable.py b/modui/filetable.py new file mode 100644 index 0000000..7be0f1b --- /dev/null +++ b/modui/filetable.py @@ -0,0 +1,72 @@ +from textual.widgets import DataTable, Static +from textual.reactive import Reactive +from textual.message import Message, MessageTarget +from textual import events, log +from jinja2.filters import do_filesizeformat + +from fhost import File +from modui import mime + +class FileTable(DataTable): + query = Reactive(None) + order_col = Reactive(0) + order_desc = Reactive(True) + limit = 10000 + colmap = [File.id, File.removed, File.nsfw_score, None, File.ext, File.size, File.mime] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_columns("#", "☣️", "🔞", "📂", "name", "size", "mime") + self.base_query = File.query.filter(File.size != None) + self.query = self.base_query + + class Selected(Message): + def __init__(self, sender: MessageTarget, f: File) -> None: + self.file = f + super().__init__(sender) + + def watch_order_col(self, old, value) -> None: + self.watch_query(None, None) + + def watch_order_desc(self, old, value) -> None: + self.watch_query(None, None) + + def watch_query(self, old, value) -> None: + def fmt_file(f: File) -> tuple: + return ( + str(f.id), + "🔴" if f.removed else " ", + "🚩" if f.is_nsfw else " ", + "👻" if not f.getpath().is_file() else " ", + f.getname(), + do_filesizeformat(f.size, True), + f"{mime.mimemoji.get(f.mime.split('/')[0], mime.mimemoji.get(f.mime)) or ' '} " + f.mime, + ) + + if (self.query): + self.clear() + order = FileTable.colmap[self.order_col] + q = self.query + if order: q = q.order_by(order.desc() if self.order_desc else order, File.id) + self.add_rows(map(fmt_file, q.limit(self.limit))) + + def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: + region = self._get_cell_region(self.cursor_row, 0) + spacing = self._get_cell_border() + self.scroll_to_region(region, animate=animate, spacing=spacing) + + async def watch_cursor_cell(self, old, value) -> None: + super().watch_cursor_cell(old, value) + if value[0] < len(self.data) and value[0] >= 0: + f = File.query.get(int(self.data[value[0]][0])) + await self.emit(self.Selected(self, f)) + + def on_click(self, event: events.Click) -> None: + super().on_click(event) + meta = self.get_style_at(event.x, event.y).meta + if meta: + if meta["row"] == -1: + qi = FileTable.colmap[meta["column"]] + if meta["column"] == self.order_col: + self.order_desc = not self.order_desc + self.order_col = meta["column"] diff --git a/modui/mime.py b/modui/mime.py new file mode 100644 index 0000000..c94e800 --- /dev/null +++ b/modui/mime.py @@ -0,0 +1,122 @@ +from enum import Enum +from textual import log + +mimemoji = { + "audio" : "🔈", + "video" : "🎞", + "text" : "📄", + "image" : "🖼", + "application/zip" : "🗜️", + "application/x-zip-compressed" : "🗜️", + "application/x-tar" : "🗄", + "application/x-cpio" : "🗄", + "application/x-xz" : "🗜️", + "application/x-7z-compressed" : "🗜️", + "application/gzip" : "🗜️", + "application/zstd" : "🗜️", + "application/x-rar" : "🗜️", + "application/x-rar-compressed" : "🗜️", + "application/vnd.ms-cab-compressed" : "🗜️", + "application/x-bzip2" : "🗜️", + "application/x-lzip" : "🗜️", + "application/x-iso9660-image" : "💿", + "application/pdf" : "📕", + "application/epub+zip" : "📕", + "application/mxf" : "🎞", + "application/vnd.android.package-archive" : "📦", + "application/vnd.debian.binary-package" : "📦", + "application/x-rpm" : "📦", + "application/x-dosexec" : "⚙", + "application/x-execuftable" : "⚙", + "application/x-sharedlib" : "⚙", + "application/java-archive" : "☕", + "application/x-qemu-disk" : "🖴", + "application/pgp-encrypted" : "🔏", +} + +MIMECategory = Enum("MIMECategory", + ["Archive", "Text", "AV", "Document", "Fallback"] +) + +class MIMEHandler: + def __init__(self): + self.handlers = { + MIMECategory.Archive : [[ + "application/zip", + "application/x-zip-compressed", + "application/x-tar", + "application/x-cpio", + "application/x-xz", + "application/x-7z-compressed", + "application/gzip", + "application/zstd", + "application/x-rar", + "application/x-rar-compressed", + "application/vnd.ms-cab-compressed", + "application/x-bzip2", + "application/x-lzip", + "application/x-iso9660-image", + "application/vnd.android.package-archive", + "application/vnd.debian.binary-package", + "application/x-rpm", + "application/java-archive", + "application/vnd.openxmlformats" + ], []], + MIMECategory.Text : [["text"], []], + MIMECategory.AV : [[ + "audio", "video", "image", + "application/mxf" + ], []], + MIMECategory.Document : [[ + "application/pdf", + "application/epub", + "application/x-mobipocket-ebook", + ], []], + MIMECategory.Fallback : [[], []] + } + + self.exceptions = { + MIMECategory.Archive : { + ".cbz" : MIMECategory.Document, + ".xps" : MIMECategory.Document, + ".epub" : MIMECategory.Document, + }, + MIMECategory.Text : { + ".fb2" : MIMECategory.Document, + } + } + + def register(self, category, handler): + self.handlers[category][1].append(handler) + + def handle(self, mime, ext): + def getcat(s): + cat = MIMECategory.Fallback + for k, v in self.handlers.items(): + s = s.split(";")[0] + if s in v[0] or s.split("/")[0] in v[0]: + cat = k + break + + for x in v[0]: + if s.startswith(x): + cat = k + break + + if cat in self.exceptions: + cat = self.exceptions[cat].get(ext) or cat + + return cat + + cat = getcat(mime) + for handler in self.handlers[cat][1]: + try: + if handler(cat): return + except: pass + + for handler in self.handlers[MIMECategory.Fallback][1]: + try: + if handler(None): return + except: pass + + raise RuntimeError(f"Unhandled MIME type category: {cat}") diff --git a/modui/mpvwidget.py b/modui/mpvwidget.py new file mode 100644 index 0000000..50e5859 --- /dev/null +++ b/modui/mpvwidget.py @@ -0,0 +1,88 @@ +import time +import fcntl, struct, termios +from sys import stdout + +from textual import events, log +from textual.widgets import Static + +from fhost import app as fhost_app + +class MpvWidget(Static): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.mpv = None + self.vo = fhost_app.config.get("MOD_PREVIEW_PROTO") + + if not self.vo in ["sixel", "kitty"]: + self.update("⚠ Previews not enabled. \n\nSet MOD_PREVIEW_PROTO to 'sixel' or 'kitty' in config.py,\nwhichever is supported by your terminal.") + else: + try: + import mpv + self.mpv = mpv.MPV() + self.mpv.profile = "sw-fast" + self.mpv["vo"] = self.vo + self.mpv[f"vo-{self.vo}-config-clear"] = False + self.mpv[f"vo-{self.vo}-alt-screen"] = False + self.mpv[f"vo-sixel-buffered"] = True + self.mpv["audio"] = False + self.mpv["loop-file"] = "inf" + self.mpv["image-display-duration"] = 0.5 if self.vo == "sixel" else "inf" + except Exception as e: + self.mpv = None + self.update(f"⚠ Previews require python-mpv with libmpv 0.36.0 or later \n\nError was:\n{type(e).__name__}: {e}") + + def start_mpv(self, f: str|None = None, pos: float|str|None = None) -> None: + self.display = True + self.screen._refresh_layout() + + if self.mpv: + if self.content_region.x: + r, c, w, h = struct.unpack('hhhh', fcntl.ioctl(0, termios.TIOCGWINSZ, '12345678')) + width = int((w / c) * self.content_region.width) + height = int((h / r) * (self.content_region.height + (1 if self.vo == "sixel" else 0))) + self.mpv[f"vo-{self.vo}-left"] = self.content_region.x + 1 + self.mpv[f"vo-{self.vo}-top"] = self.content_region.y + 1 + self.mpv[f"vo-{self.vo}-rows"] = self.content_region.height + (1 if self.vo == "sixel" else 0) + self.mpv[f"vo-{self.vo}-cols"] = self.content_region.width + self.mpv[f"vo-{self.vo}-width"] = width + self.mpv[f"vo-{self.vo}-height"] = height + + if pos != None: + self.mpv["start"] = pos + + if f: + self.mpv.loadfile(f) + else: + self.mpv.playlist_play_index(0) + + def stop_mpv(self, wait: bool = False) -> None: + if self.mpv: + if not self.mpv.idle_active: + self.mpv.stop(True) + if wait: + time.sleep(0.1) + self.clear_mpv() + self.display = False + + def on_resize(self, size) -> None: + if self.mpv: + if not self.mpv.idle_active: + t = self.mpv.time_pos + self.stop_mpv() + if t: + self.mpv["start"] = t + self.start_mpv() + + def clear_mpv(self) -> None: + if self.vo == "kitty": + stdout.write("\033_Ga=d;\033\\") + stdout.flush() + + def shutdown(self) -> None: + if self.mpv: + self.mpv.stop() + del self.mpv + if self.vo == "kitty": + stdout.write("\033_Ga=d;\033\\\033[?25l") + stdout.flush() diff --git a/modui/notification.py b/modui/notification.py new file mode 100644 index 0000000..ecae6e4 --- /dev/null +++ b/modui/notification.py @@ -0,0 +1,8 @@ +from textual.widgets import Static + +class Notification(Static): + def on_mount(self) -> None: + self.set_timer(3, self.remove) + + def on_click(self) -> None: + self.remove() -- cgit v1.2.3