diff --git a/app.py b/app.py new file mode 100644 index 0000000..6c3b581 --- /dev/null +++ b/app.py @@ -0,0 +1,58 @@ +from flask import Flask, render_template, url_for, jsonify, send_file +from datetime import datetime +from pathlib import Path + +from src.iris_api import Timetable + +def compile_javascript(base_path = "static"): + js_file_ptrs = list(Path(base_path).glob("*.js")) + js_files = [] + + print(js_file_ptrs) + + for file in js_file_ptrs: + print(file.name) + fname = f"{file.name}" + js_files.append(url_for('static', filename=fname)) + + return js_files + +eva = 8002377 + +app = Flask(__name__) + +@app.route("/favicon.ico") +def favicon(): + return send_file("static/images/favicon.ico") + +@app.route("/") +def main_page(): + # print(compile_javascript()) + + return render_template( + 'base.html', + title="testin", + station="Gröbenzell", + current_time_str=datetime.now().strftime(r"%H:%M:%S"), + js_files=compile_javascript() + ) + +# TODO: add a text entry field to change the EVA +# @app.route("/setup") +# def setup(): +# return "" + +@app.route("/update", methods=['POST']) +def update(): + tt = Timetable(eva) + + tt.get_stops() + + table_data = tt.str_stops() + # print(table_data[0]) + + + result = {'status': 'success'} + # result.update(data) + result["table_data"] = table_data + return jsonify(result) \ No newline at end of file diff --git a/main.py b/main.py index 8b43248..432b863 100644 --- a/main.py +++ b/main.py @@ -1,271 +1,47 @@ -from dataclasses import dataclass, field -from src.api import Timetable, Stop - +from flask import Flask import fire -import cv2 -from PIL import ImageFont, ImageDraw, Image -import numpy as np -import time +from htpy import body, h1, head, html, title, table, tr, td, div, script -badge_colors = { - "S1": {"fill": "#18bae7", "text": "#C6D3D7"}, - "S2": {"fill": "#74b72b", "text": "#C6D3D7"}, - "S3": {"fill": "#941d81", "text": "#C6D3D7"}, - "S4": {"fill": "#e20b1c", "text": "#C6D3D7"}, - "S5": {"fill": "#005280", "text": "#C6D3D7"}, - "S6": {"fill": "#008c59", "text": "#C6D3D7"}, - "S7": {"fill": "#892f24", "text": "#C6D3D7"}, - "S8": {"fill": "#0d0d11", "text": "#eeaa00"}, - "S20": {"fill": "#e8526d", "text": "#C6D3D7"}, -} +app = Flask(__name__) -@dataclass -class ImageSetup: - width: int = 1920 - height: int = 1080 - bg_color: str = "#282a35" - fonts: dict = field( - default_factory=lambda: { - "title": ("./fonts/Rubik-Bold.ttf", 80), - "badge": ("./fonts/Rubik-SemiBold.ttf", 70), - "column_title": ("./fonts/Rubik-Regular.ttf", 50), - "default": ("./fonts/Rubik-Regular.ttf", 60), - } +def run_server(debug=False): + app.run(debug=debug) + +@app.route("/") +def main_page(): + return build_page(None) + +def build_page(tt, debug=True): + _ = tt + from datetime import datetime + + lines = ( + ("S1", "somewhere", datetime.now().strftime(r"%H:%M:%S"), 4, "3 min"), + ("S2", "else", datetime.now().strftime(r"%H:%M:%S"), 4, "3 min"), ) - text_colors: dict = field( - default_factory=lambda: { - "title": "#899194", - "column_title": "#555A5C", - "default": "#899194" - } - ) - margins: tuple = (60, 60, 60, 60) # t, b, l, r + retval = html[ + head[title["today's menu"]], + body[ + div(style="clear: both")[ + h1(style="float: left")["Gröbenzell"], + h1(style="float: right", id="clock")[datetime.now().strftime(r"%H:%M:%S")], + ], + table[ + (tr[ + (td[element] for element in line) + ] for line in lines) + ], + ] + ] + retval = str(retval) -def denormalize(coords, setup: ImageSetup, use_margins=True): - (x, y) = coords - margins = setup.margins if use_margins else (0, 0, 0, 0) - return ( - int(x * (setup.width - margins[2] - margins[3]) + margins[2]), - int(y * (setup.height - margins[0] - margins[1]) + margins[0]), - ) - -def create_image(tt: Timetable, setup: ImageSetup | None = None): - if setup is None: - setup = ImageSetup() - - # img = np.zeros((setup.height, setup.width, 3), dtype=np.uint8) - img = Image.new("RGBA", (setup.width, setup.height), setup.bg_color) - - # # fill with background color - # for i, c in enumerate(hex2color(setup.bg_color)): - # img[:, :, i] = int(c * 255) - - ### setup done ### - - draw = ImageDraw.Draw(img) - - title_font = ImageFont.truetype(*setup.fonts["title"]) - ctitle_font = ImageFont.truetype(*setup.fonts["column_title"]) - badge_font = ImageFont.truetype(*setup.fonts["badge"]) - default_font = ImageFont.truetype(*setup.fonts["default"]) - - draw.text( - xy=denormalize((0, 0), setup), - text=tt.station, - font=title_font, - anchor="la", - fill=setup.text_colors["title"] - ) - - draw.text( - xy=denormalize((0.815, 0), setup), - text=tt.timestamp.strftime(r"%H:%M:%S"), - font=title_font, - anchor="la", - fill=setup.text_colors["title"] - ) - - - - line_badge_size = (2*setup.fonts["badge"][1], setup.fonts["badge"][1]) - line_height = setup.fonts["badge"][1]*1.75 - - xy_start = denormalize((0,0.15), setup) - dest_x = denormalize((0.12, 0), setup)[0] - dep_x = denormalize((0.5, 0), setup)[0] - delay_x = denormalize((0.6, 0), setup)[0] - max_dest_len = dep_x - dest_x - 60 - until_x = denormalize((0.5+0.5-0.12, 0), setup)[0] - # col_title_y = denormalize((0, 0.15), setup)[1] - - # draw.text( - # xy=(dest_x, col_title_y), - # text="Richtung", - # font=ctitle_font, - # anchor="lm", - # fill=setup.text_colors["column_title"], - # ) - - # draw.text( - # xy=(dep_x, col_title_y), - # text="Zeit", - # font=ctitle_font, - # anchor="lm", - # fill=setup.text_colors["column_title"], - # ) - - # draw.text( - # xy=(until_x, col_title_y), - # text="fährt in", - # font=ctitle_font, - # anchor="lm", - # fill=setup.text_colors["column_title"], - # ) - - current_xys_line_badge = [*xy_start, xy_start[0]+line_badge_size[0], xy_start[1]+line_badge_size[1]] - for stop in tt.stops[:min(tt.min_stop_count, len(tt.stops), 7)]: - stop: Stop = stop - draw.rounded_rectangle( - current_xys_line_badge, - radius=min(current_xys_line_badge[2]-current_xys_line_badge[0], current_xys_line_badge[3]-current_xys_line_badge[1])//2, - fill=badge_colors[stop.line]["fill"], - outline=badge_colors[stop.line]["fill"], - width=10 - ) - - center_xy = ((current_xys_line_badge[0]+current_xys_line_badge[2])//2, (current_xys_line_badge[1]+current_xys_line_badge[3])//2) - - draw.text( - xy=center_xy, - text=stop.line, - font=badge_font, - anchor="mm", - fill=badge_colors[stop.line]["text"], - ) - - dest_text :str = stop.destination - dest_text = dest_text.replace("München-", "") - dest_text = dest_text.replace("München ", "") - paren_index = dest_text.find("(") - if paren_index != -1: - dest_text = dest_text[:paren_index] - - cutoff = 0 - while default_font.getlength(dest_text) > max_dest_len: - dest_text = (dest_text[:len(dest_text)-cutoff+1]+ ("." if cutoff > 0 else "")).replace(" .", "") - cutoff += 1 - - dep_text = stop.departure_time.strftime(r"%H:%M") - - time_until = round((stop.departure_time - tt.timestamp).total_seconds()/60) - - until_text = f"{time_until:d} min" - - until_color = setup.text_colors["default"] - delay_color = setup.text_colors["column_title"] - dep_color = setup.text_colors["default"] - dest_color = setup.text_colors["default"] - - delay_text = "" - - if stop.departure_delay is not None: - delay_text = f"{"+" if stop.departure_delay >= 0 else "-"} {abs(stop.departure_delay):d}" - if stop.departure_delay > 5: - delay_color = "#c8683f" - elif stop.departure_delay < 0: - delay_color = "#48c144" - - if time_until <= 6: - until_color = "#c8683f" - elif 6 < time_until <= 10: - until_color = "#b79b46" - - if stop.departure_canceled: - dep_color = "#c83f4e" - dest_color = "#c83f4e" - dep_text = "Fällt aus :(" - until_text = "----" - delay_text = "" - - draw.text( - xy=(dest_x, center_xy[1]), - text=dest_text, - font=default_font, - anchor="lm", - fill=dest_color, - ) - - draw.text( - xy=(dep_x, center_xy[1]), - text=dep_text, - font=default_font, - anchor="lm", - fill=dep_color, - ) - - draw.text( - xy=(delay_x, center_xy[1]), - text=delay_text, - font=ctitle_font, - anchor="lm", - fill=delay_color, - ) - - draw.text( - xy=(until_x, center_xy[1]), - text=until_text, - font=default_font, - anchor="lm", - fill=until_color, - ) - - current_xys_line_badge[1] += line_height - current_xys_line_badge[3] += line_height - - - # if gif is not None: - # gif = Image.open(gif) - # gif.seek(gif_frame % gif.n_frames) - # frame = gif.convert("RGBA") - # frame_size = frame.size - # frame_anchor = denormalize((1, 1), setup, use_margins=False) - # img.paste(frame, (frame_anchor[0]-frame_size[0], frame_anchor[1]-frame_size[1]), frame) - - # last step, convert from rgb to bgr - img = img.convert("RGB") - img = np.array(img) - img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) - return img - - -def main(eva=None): - eva = 8002377 if eva is None else eva - # eva = 8098263 - # eva = 8004158 - - cv2.namedWindow("fs", cv2.WND_PROP_FULLSCREEN) - cv2.setWindowProperty("fs", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) - # cv2.moveWindow("fs", -1920, 0) - - tt = Timetable(eva=eva) - - while True: - start = time.time() - tt.get_stops() - img = create_image(tt) - cv2.imshow("fs", img) - - if cv2.waitKey(1) & 0xFF == ord("q"): - break - - elapsed = time.time() - start - time.sleep(max(0, 1.0 - elapsed)) - - cv2.destroyAllWindows() + if debug: + print(retval) + return retval if __name__ == "__main__": - fire.Fire(main) + fire.Fire(run_server) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0619329..1749755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "cachetools>=6.1.0", "datetime>=5.5", "fire>=0.7.1", + "flask>=3.1.2", + "htpy>=25.8.1", "opencv-contrib-python>=4.12.0.88", "opencv-python>=4.12.0.88", "pillow>=11.3.0", diff --git a/src/image.py b/src/image.py new file mode 100644 index 0000000..34b9e0d --- /dev/null +++ b/src/image.py @@ -0,0 +1,238 @@ +from dataclasses import dataclass, field +from iris_api import Timetable, Stop + +import cv2 +from PIL import ImageFont, ImageDraw, Image +import numpy as np + +badge_colors = { + "S1": {"fill": "#18bae7", "text": "#C6D3D7"}, + "S2": {"fill": "#74b72b", "text": "#C6D3D7"}, + "S3": {"fill": "#941d81", "text": "#C6D3D7"}, + "S4": {"fill": "#e20b1c", "text": "#C6D3D7"}, + "S5": {"fill": "#005280", "text": "#C6D3D7"}, + "S6": {"fill": "#008c59", "text": "#C6D3D7"}, + "S7": {"fill": "#892f24", "text": "#C6D3D7"}, + "S8": {"fill": "#0d0d11", "text": "#eeaa00"}, + "S20": {"fill": "#e8526d", "text": "#C6D3D7"}, +} + +@dataclass +class ImageSetup: + width: int = 1920 + height: int = 1080 + bg_color: str = "#282a35" + fonts: dict = field( + default_factory=lambda: { + "title": ("./fonts/Rubik-Bold.ttf", 80), + "badge": ("./fonts/Rubik-SemiBold.ttf", 70), + "column_title": ("./fonts/Rubik-Regular.ttf", 50), + "default": ("./fonts/Rubik-Regular.ttf", 60), + } + ) + + text_colors: dict = field( + default_factory=lambda: { + "title": "#899194", + "column_title": "#555A5C", + "default": "#899194" + } + ) + margins: tuple = (60, 60, 60, 60) # t, b, l, r + + +def denormalize(coords, setup: ImageSetup, use_margins=True): + (x, y) = coords + margins = setup.margins if use_margins else (0, 0, 0, 0) + return ( + int(x * (setup.width - margins[2] - margins[3]) + margins[2]), + int(y * (setup.height - margins[0] - margins[1]) + margins[0]), + ) + +def create_image(tt: Timetable, setup: ImageSetup | None = None): + if setup is None: + setup = ImageSetup() + + # img = np.zeros((setup.height, setup.width, 3), dtype=np.uint8) + img = Image.new("RGBA", (setup.width, setup.height), setup.bg_color) + + # # fill with background color + # for i, c in enumerate(hex2color(setup.bg_color)): + # img[:, :, i] = int(c * 255) + + ### setup done ### + + draw = ImageDraw.Draw(img) + + title_font = ImageFont.truetype(*setup.fonts["title"]) + ctitle_font = ImageFont.truetype(*setup.fonts["column_title"]) + badge_font = ImageFont.truetype(*setup.fonts["badge"]) + default_font = ImageFont.truetype(*setup.fonts["default"]) + + draw.text( + xy=denormalize((0, 0), setup), + text=tt.station, + font=title_font, + anchor="la", + fill=setup.text_colors["title"] + ) + + draw.text( + xy=denormalize((0.815, 0), setup), + text=tt.timestamp.strftime(r"%H:%M:%S"), + font=title_font, + anchor="la", + fill=setup.text_colors["title"] + ) + + + + line_badge_size = (2*setup.fonts["badge"][1], setup.fonts["badge"][1]) + line_height = setup.fonts["badge"][1]*1.75 + + xy_start = denormalize((0,0.15), setup) + dest_x = denormalize((0.12, 0), setup)[0] + dep_x = denormalize((0.5, 0), setup)[0] + delay_x = denormalize((0.6, 0), setup)[0] + max_dest_len = dep_x - dest_x - 60 + until_x = denormalize((0.5+0.5-0.12, 0), setup)[0] + # col_title_y = denormalize((0, 0.15), setup)[1] + + # draw.text( + # xy=(dest_x, col_title_y), + # text="Richtung", + # font=ctitle_font, + # anchor="lm", + # fill=setup.text_colors["column_title"], + # ) + + # draw.text( + # xy=(dep_x, col_title_y), + # text="Zeit", + # font=ctitle_font, + # anchor="lm", + # fill=setup.text_colors["column_title"], + # ) + + # draw.text( + # xy=(until_x, col_title_y), + # text="fährt in", + # font=ctitle_font, + # anchor="lm", + # fill=setup.text_colors["column_title"], + # ) + + current_xys_line_badge = [*xy_start, xy_start[0]+line_badge_size[0], xy_start[1]+line_badge_size[1]] + for stop in tt.stops[:min(tt.min_stop_count, len(tt.stops), 7)]: + stop: Stop = stop + draw.rounded_rectangle( + current_xys_line_badge, + radius=min(current_xys_line_badge[2]-current_xys_line_badge[0], current_xys_line_badge[3]-current_xys_line_badge[1])//2, + fill=badge_colors[stop.line]["fill"], + outline=badge_colors[stop.line]["fill"], + width=10 + ) + + center_xy = ((current_xys_line_badge[0]+current_xys_line_badge[2])//2, (current_xys_line_badge[1]+current_xys_line_badge[3])//2) + + draw.text( + xy=center_xy, + text=stop.line, + font=badge_font, + anchor="mm", + fill=badge_colors[stop.line]["text"], + ) + + dest_text :str = stop.destination + dest_text = dest_text.replace("München-", "") + dest_text = dest_text.replace("München ", "") + paren_index = dest_text.find("(") + if paren_index != -1: + dest_text = dest_text[:paren_index] + + cutoff = 0 + while default_font.getlength(dest_text) > max_dest_len: + dest_text = (dest_text[:len(dest_text)-cutoff+1]+ ("." if cutoff > 0 else "")).replace(" .", "") + cutoff += 1 + + dep_text = stop.departure_time.strftime(r"%H:%M") + + time_until = round((stop.departure_time - tt.timestamp).total_seconds()/60) + + until_text = f"{time_until:d} min" + + until_color = setup.text_colors["default"] + delay_color = setup.text_colors["column_title"] + dep_color = setup.text_colors["default"] + dest_color = setup.text_colors["default"] + + delay_text = "" + + if stop.departure_delay is not None: + delay_text = f"{"+" if stop.departure_delay >= 0 else "-"} {abs(stop.departure_delay):d}" + if stop.departure_delay > 5: + delay_color = "#c8683f" + elif stop.departure_delay < 0: + delay_color = "#48c144" + + if time_until <= 6: + until_color = "#c8683f" + elif 6 < time_until <= 10: + until_color = "#b79b46" + + if stop.departure_canceled: + dep_color = "#c83f4e" + dest_color = "#c83f4e" + dep_text = "Fällt aus :(" + until_text = "----" + delay_text = "" + + draw.text( + xy=(dest_x, center_xy[1]), + text=dest_text, + font=default_font, + anchor="lm", + fill=dest_color, + ) + + draw.text( + xy=(dep_x, center_xy[1]), + text=dep_text, + font=default_font, + anchor="lm", + fill=dep_color, + ) + + draw.text( + xy=(delay_x, center_xy[1]), + text=delay_text, + font=ctitle_font, + anchor="lm", + fill=delay_color, + ) + + draw.text( + xy=(until_x, center_xy[1]), + text=until_text, + font=default_font, + anchor="lm", + fill=until_color, + ) + + current_xys_line_badge[1] += line_height + current_xys_line_badge[3] += line_height + + + # if gif is not None: + # gif = Image.open(gif) + # gif.seek(gif_frame % gif.n_frames) + # frame = gif.convert("RGBA") + # frame_size = frame.size + # frame_anchor = denormalize((1, 1), setup, use_margins=False) + # img.paste(frame, (frame_anchor[0]-frame_size[0], frame_anchor[1]-frame_size[1]), frame) + + # last step, convert from rgb to bgr + img = img.convert("RGB") + img = np.array(img) + img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + return img \ No newline at end of file diff --git a/src/api.py b/src/iris_api.py similarity index 86% rename from src/api.py rename to src/iris_api.py index 902bbe6..e58ce42 100644 --- a/src/api.py +++ b/src/iris_api.py @@ -62,6 +62,26 @@ class Stop: else: return "" + def to_dict(self): + return { + "id": self.id, + "line": self.line, + "departure_time": self.departure_time.timestamp(), + "old_departure_time": self.old_departure_time.timestamp(), + "departure_delay": 0 if self.departure_delay is None else self.departure_delay, + "departure_canceled": self.departure_canceled, + "departure_cancellation_time": -1 if self.departure_cancellation_time is None else self.departure_cancellation_time.timestamp(), + "platform": self.platform, + "path": self.path, + "destination": self.destination, + "origin": "" if self.origin is None else self.origin, + "arrival_time": -1 if self.arrival_time is None else self.arrival_time.timestamp(), + "old_arrival_time": -1 if self.old_arrival_time is None else self.old_arrival_time.timestamp(), + "arrival_delay": 0 if self.arrival_delay is None else self.arrival_delay, + "arrival_canceled": self.arrival_canceled, + "arrival_cancellation_time": -1 if self.arrival_cancellation_time is None else self.arrival_cancellation_time.timestamp(), + } + def __repr__(self): if not self.departure_canceled: return f"{self.line} to {self.destination}: {self.departure_time.strftime(r"%Y-%m-%d %H:%M")}{f" ({self.departure_delay:+d})" if self.departure_delay is not None else ""} @ platform {self.platform}" @@ -90,6 +110,18 @@ class Timetable: self.station = changes['timetable']['@station'] return changes['timetable']['s'] + + def str_stops(self): + stops = [] + for stop in self.stops: + stop_dict = stop.to_dict() + stop_dict["until_departure"] = stop_dict["departure_time"] - self.timestamp.timestamp() + stop_dict["until_arrival"] = stop_dict["arrival_time"] - self.timestamp.timestamp() + stop_dict["timestamp"] = self.timestamp.timestamp() + stops.append(stop_dict) + return stops + + def fetch_actual(self, stop: Stop): changes = self.get_changes() for event in changes: diff --git a/static/images/favicon.ico b/static/images/favicon.ico new file mode 100644 index 0000000..b2c4e69 Binary files /dev/null and b/static/images/favicon.ico differ diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..66399e4 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,199 @@ +/* .header-box { + display: flex; + justify-content: space-between; /* pushes items to opposite sides + align-items: top; /* vertically center them + width: 100%; + height: 12%; + font-size: 120%; +} + +.header-box h1 { + margin: 0 2.5% 2.5% 2.5%; +} */ + +html, body { + /* margin: 0; */ + padding: 0; + height: 100%; + overflow: hidden; /* no scrollbars */ + font-size: 150%; + font-family: "Rubik", sans-serif; + font-optical-sizing: auto; + font-weight: 500; + font-style: normal; + background: #282a35; + color: #899194; +} + +.header-table-container { + width: 95%; + margin: 0 2.5% 1% 2.5%; + height: 12%; + display: flex; + flex-direction: column; + justify-content: center; +} + +.header-table { + width: 100%; + height: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 120%; +} + +.header-station { + width: 79%; +} + +/* .header-clock { + width: 15%; +} */ + +.line { + width: 6%; + /* text-align: left; */ + /* padding: 0 0 0 0; */ + vertical-align: 25%; +} + +.destination{ + padding: 100em; +} + +.departure_time { + width: 12%; + text-align: center; +} + +.departure_delay { + width: 10%; + text-align: left; + font-size: 50%; + color: #646a6c +} + +.until_departure { + width: 16%; + text-align: right; +} + +.departure-table-container { + width: 95%; + margin: 0 2.5% 2.5% 2.5%; + height: 83%; + display: flex; + flex-direction: column; + justify-content: center; +} + +.departure-table { + width: 100%; + height: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 200%; +} + + +.departure-table th, +.departure-table td { + padding: 0 0.25em 0 0; + /* vertical-align: middle; */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + /* border: 1px solid #8de8fa; */ + /* height: 12.5%; */ +} + +.departure-table td:last-child, +.departure-table th:last-child { + padding-right: 0; +} + +.line-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2em 0.5em; /* relative padding */ + font-weight: bold; + font-size: 0.75em; + border-radius: 9999px; /* pill shape adjusts automatically */ + height: 100%; /* takes most of cell height */ + max-height: 100%; /* don’t overflow cell */ + width: 100%; + max-width: 100%; /* stay within cell */ + aspect-ratio: 2 / 1; + box-sizing: border-box; + white-space: nowrap; /* prevent text wrapping */ + overflow: hidden; /* hide overflow if text is too long */ + text-overflow: ellipsis; /* show "..." if necessary */ +} + +/* Colors for different lines */ +.line-S1 { + background-color: #14bae7; + color: #b5b5b5; +} + +.line-S2 { + background-color: #75b728; + color: #b5b5b5; +} + +.line-S3 { + background-color: #951781; + color: #b5b5b5; +} + +.line-S4 { + background-color: #e30b1b; + color: #b5b5b5; +} + +.line-S5 { + background-color: #00517f; + color: #b5b5b5; +} + +.line-S6 { + background-color: #008c58; + color: #b5b5b5; +} + +.line-S7 { + background-color: #882d22; + color: #b5b5b5; +} + +.line-S8 { + background-color: #000000; + color: #f0a901; +} + +.line-S20 { + background-color: #ea516c; + color: #b5b5b5; +} + +.line-default { + background-color: #bdc3c7; + color: #1f2831; +} + +.delay-high { + color: #dc4d21; +} + +.delay-neg { + color: #5bc812 +} + +.until-low { + color: #dc4d21 +} + +.until-medium { + color: #dc9e21 +} diff --git a/static/update_time.js b/static/update_time.js new file mode 100644 index 0000000..3e86aaf --- /dev/null +++ b/static/update_time.js @@ -0,0 +1,171 @@ +(function () { + + + function updateClock ( clock ) { + clock.innerHTML = new Date().toLocaleTimeString(); + } + + function ensureTableSize(table, x, y) { + // Adjust number of rows + while (table.rows.length < x) { + table.insertRow(); + } + while (table.rows.length > x) { + table.deleteRow(table.rows.length - 1); + } + + // Ensure each row has y cells + for (let row of table.rows) { + while (row.cells.length < y) { + row.insertCell(); + } + while (row.cells.length > y) { + row.deleteCell(row.cells.length - 1); + } + } + } + + function formatWithSign(num) { + if (num > 0) { + return `+ ${num}`; + } else if (num < 0) { + return `- ${Math.abs(num)}`; + } else { + return ""; + } + } + + function cssClassExists(className) { + return Array.from(document.styleSheets).some(sheet => { + try { + return Array.from(sheet.cssRules).some(rule => + rule.selectorText === `.${className}` + ); + } catch (e) { + // Ignore CORS-restricted stylesheets + return false; + } + }); + } + + function redraw_table(data, table) { + // const res = JSON.parse(result) + const columns = { + "line": 0, + "destination": 1, + "departure_time": 2, + "departure_delay": 3, + "until_departure": 4 + } + + // console.log(data) + + ensureTableSize(table, Math.min(data.length, 8), 5); + + for (let i = 0; i < Math.min(data.length, 8); i++) { + const row = table.rows[i]; + // row.classList.add("departure-table-row") + // row.style.height = `$10%`; + const train = Object.entries(data[i]); + for (let [key, value] of train) { + if (key in columns) { + const cell = row.cells[columns[key]]; // Get the cell once + cell.classList.add(key); // Add class based on column name + if (key == "line") { + // cell.textContent = String(value); + const lineClass = `line-${value}`; + const appliedClass = cssClassExists(lineClass) ? lineClass : "line-default"; + cell.innerHTML = `${value}`; + const pill = document.querySelector('.line-pill'); + + const cellHeight = cell.clientHeight; + const cellWidth = cell.clientWidth; + + // Set maximum sizes dynamically + pill.style.maxWidth = `${cellHeight * 2}px`; + pill.style.maxHeight = `${cellWidth * 0.5}px`; + } + if (key == "destination") { + dest = String(value); + dest = dest.replace(/\s*\(.*?\)\s*/g, "") + .replace(/^München\s*/i, "") + .replace(/^[\s-]+|[\s-]+$/g, ""); + cell.textContent = dest; + } + if (key == "departure_time") { + if (train["canceled"]) { + cell.textContent = "Fällt aus :("; + cell.classList.add("train-canceled"); + } else { + const date = new Date(value*1000); + cell.textContent = date.toLocaleTimeString("de-DE", + { + hour: "2-digit", + minute: "2-digit" + } + ); + } + } + if (key == "departure_delay") { + if (train["canceled"]) { + cell.textContent = ""; + } else { + cell.textContent = formatWithSign(value); + if (value >= 5) { + cell.classList.add("delay-high"); + } else if (value < 0) { + cell.classList.add("delay-neg"); + } + } + } + if (key == "until_departure") { + if (train["canceled"]) { + cell.textContent = ""; + } else { + const minutes = Math.round(value/60); + cell.textContent = String(minutes) + " min"; + if (minutes <= 6) { + cell.classList.add("until-low"); + } else if (minutes <= 10) { + cell.classList.add("until-medium"); + } + } + } + // cell.innerHTML.classList.add("scrolling-text") + } + } + } + // document.querySelector('.departure-table') + // .style.setProperty('--row-count', table.rows.length); + } + + function updateTable( table ){ + fetch('/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + // TODO: pass number of rows we want + }) + .then(response => response.json()) + .then(result => { + // console.log(result); + redraw_table(result.table_data, table); + // const res = JSON.parse(result); + // console.log(res.table_data); + // for (const elem of res.table_data) { + // console.log(elem); + // } + }); + } + + var clockElement = document.getElementById( "clock" ); + var tableElement = document.getElementById( "departures" ); + + setInterval(function () { + updateClock( clockElement ); + updateTable( tableElement); + }, 1000); + +}()); + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ce3ee47 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,40 @@ + + + + Abfahrten {{ station }} + + + + + + +
+ + + + + + +
+ +
+
+
+ + {% for file in js_files %} + + {% endfor %} + diff --git a/tt_to_image.py b/tt_to_image.py new file mode 100644 index 0000000..da470cb --- /dev/null +++ b/tt_to_image.py @@ -0,0 +1,37 @@ +from iris_api import Timetable + +from src.image import create_image + +import fire + +import cv2 +import time + +def main(eva=None): + eva = 8002377 if eva is None else eva + # eva = 8098263 + # eva = 8004158 + + cv2.namedWindow("fs", cv2.WND_PROP_FULLSCREEN) + cv2.setWindowProperty("fs", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) + # cv2.moveWindow("fs", -1920, 0) + + tt = Timetable(eva=eva) + + while True: + start = time.time() + tt.get_stops() + img = create_image(tt) + cv2.imshow("fs", img) + + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + elapsed = time.time() - start + time.sleep(max(0, 1.0 - elapsed)) + + cv2.destroyAllWindows() + + +if __name__ == "__main__": + fire.Fire(main) diff --git a/uv.lock b/uv.lock index 13316b3..526f07c 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,8 @@ dependencies = [ { name = "cachetools" }, { name = "datetime" }, { name = "fire" }, + { name = "flask" }, + { name = "htpy" }, { name = "opencv-contrib-python" }, { name = "opencv-python" }, { name = "pillow" }, @@ -23,6 +25,8 @@ requires-dist = [ { name = "cachetools", specifier = ">=6.1.0" }, { name = "datetime", specifier = ">=5.5" }, { name = "fire", specifier = ">=0.7.1" }, + { name = "flask", specifier = ">=3.1.2" }, + { name = "htpy", specifier = ">=25.8.1" }, { name = "opencv-contrib-python", specifier = ">=4.12.0.88" }, { name = "opencv-python", specifier = ">=4.12.0.88" }, { name = "pillow", specifier = ">=11.3.0" }, @@ -31,6 +35,15 @@ requires-dist = [ { name = "xmltodict", specifier = ">=0.14.2" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "6.1.0" @@ -80,6 +93,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "datetime" version = "5.5" @@ -105,6 +139,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945, upload-time = "2025-08-16T20:20:22.87Z" }, ] +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "htpy" +version = "25.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/bc/58f4b835e1ffec9441aa92f97d3ce38148fff7e46507e05f2436098db6dc/htpy-25.8.1.tar.gz", hash = "sha256:ba4ac83307cbcbf5cb2b08111f75bb978693ede1624bac75da01208882b3c48d", size = 287611, upload-time = "2025-08-15T11:34:23.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/27/67b73c2399e1f9a27d4fa8ed6f0506302b927afc5e9c4d0e2c27ceeb0520/htpy-25.8.1-py3-none-any.whl", hash = "sha256:184dc98cb39e1157fcc66ce930e5dad222955b2d97c33612277a79d7b5d8ded3", size = 18945, upload-time = "2025-08-15T11:34:22.07Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -114,6 +177,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -126,6 +210,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -325,6 +437,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + [[package]] name = "xmltodict" version = "0.14.2"