from functools import cache import json from flask import ( Flask, make_response, redirect, request, jsonify, render_template, send_from_directory, send_file, Response ) import os import json import requests from datetime import datetime, timedelta import dotenv from pytz import timezone import pytz import re from PIL import Image, ImageDraw, ImageFont import io import urllib.parse dotenv.load_dotenv() app = Flask(__name__) def find(name, path): for root, dirs, files in os.walk(path): if name in files: return os.path.join(root, name) # Assets routes @app.route("/assets/<path:path>") def send_assets(path): if path.endswith(".json"): return send_from_directory( "templates/assets", path, mimetype="application/json" ) if os.path.isfile("templates/assets/" + path): return send_from_directory("templates/assets", path) # Try looking in one of the directories filename: str = path.split("/")[-1] if ( filename.endswith(".png") or filename.endswith(".jpg") or filename.endswith(".jpeg") or filename.endswith(".svg") ): if os.path.isfile("templates/assets/img/" + filename): return send_from_directory("templates/assets/img", filename) if os.path.isfile("templates/assets/img/favicon/" + filename): return send_from_directory("templates/assets/img/favicon", filename) return render_template("404.html"), 404 # region Special routes @app.route("/favicon.png") def faviconPNG(): return send_from_directory("templates/assets/img", "favicon.png") @app.route("/.well-known/<path:path>") def wellknown(path): # Try to proxy to https://nathan.woodburn.au/.well-known/ req = requests.get(f"https://nathan.woodburn.au/.well-known/{path}") return make_response( req.content, 200, {"Content-Type": req.headers["Content-Type"]} ) # endregion # region Main routes @app.route("/") def index(): from_tz = request.args.get('from_tz', '') time = request.args.get('time', '') to_tzs = request.args.get('to_tzs', '') # Generate the OG image URL based on the parameters # Make sure to encode the parameters from_tz = urllib.parse.quote(from_tz) time = urllib.parse.quote(time) to_tzs = urllib.parse.quote(to_tzs) og_image = f"og_image?from_tz={from_tz}&time={time}&to_tzs={to_tzs}" return render_template("index.html", og_image=og_image) # Mapping of short timezone names to full names SHORT_TZ_MAP = { "GMT": "Etc/GMT", "PST": "America/Los_Angeles", "PDT": "America/Los_Angeles", "MST": "America/Denver", "MDT": "America/Denver", "CST": "America/Chicago", "CDT": "America/Chicago", "EST": "America/New_York", "EDT": "America/New_York", "AKST": "America/Anchorage", "AKDT": "America/Anchorage", "HST": "Pacific/Honolulu", "HDT": "Pacific/Honolulu", "AST": "America/Puerto_Rico", "NST": "America/St_Johns", "BST": "Europe/London", "CET": "Europe/Paris", "CEST": "Europe/Paris", "EET": "Europe/Athens", "EEST": "Europe/Athens", "MSK": "Europe/Moscow", "MSD": "Europe/Moscow", "IST": "Asia/Kolkata", "PKT": "Asia/Karachi", "WIB": "Asia/Jakarta", "WITA": "Asia/Makassar", "WIT": "Asia/Jayapura", "CST": "Asia/Shanghai", "JST": "Asia/Tokyo", "KST": "Asia/Seoul", "AEDT": "Australia/Sydney", "AEST": "Australia/Sydney", "ACST": "Australia/Adelaide", "ACDT": "Australia/Adelaide", "AWST": "Australia/Perth", } def get_full_timezone(tz): match = re.match(r"^UTC([+-]\d{1,2}):?(\d{2})?$", tz) if match: hours = int(match.group(1)) minutes = int(match.group(2)) if match.group(2) else 0 return pytz.FixedOffset(hours * 60 + minutes) full_tz = SHORT_TZ_MAP.get(tz, tz) try: return pytz.timezone(full_tz) if isinstance(full_tz, str) else None except pytz.UnknownTimeZoneError: return None @app.route("/api/v1/convert", methods=["POST"]) def convert(): data = request.json from_tz = get_full_timezone(data.get("from_tz").replace("_", "/")) to_tz = get_full_timezone(data.get("to_tz").replace("_", "/")) time = data.get("time") if from_tz is None or to_tz is None: return jsonify({"error": "Invalid timezone"}), 400 # Parse the input time try: input_time = datetime.strptime(time, "%Y-%m-%dT%H:%M:%S") except ValueError: input_time = datetime.strptime(time, "%Y-%m-%dT%H:%M") try: # Get the timezone from_tz_time = from_tz.localize(input_time) # Convert the time to_tz_time = from_tz_time.astimezone(to_tz) except Exception as e: return jsonify({"error": str(e)}), 400 return jsonify( { "from": from_tz_time.strftime("%Y-%m-%d %H:%M:%S %Z"), "to": to_tz_time.strftime("%Y-%m-%d %H:%M:%S"), } ) @app.route("/api/v1/convert_multiple", methods=["POST"]) def convert_multiple(): data = request.json from_tz = get_full_timezone(data.get("from_tz").replace("_", "/")) time = data.get("time") to_tz_list = [(tz, get_full_timezone(tz.replace("_", "/"))) for tz in data.get("to_tzs") if len(tz) > 0] if from_tz is None or any(tz[1] is None for tz in to_tz_list): return jsonify({"error": "Invalid timezone"}), 400 # Parse the input time try: input_time = datetime.strptime(time, "%Y-%m-%dT%H:%M:%S") except ValueError: input_time = datetime.strptime(time, "%Y-%m-%dT%H:%M") try: # Get the timezone from_tz_time = from_tz.localize(input_time) # Convert the time to each timezone in the list results = {} for original_tz, tz in to_tz_list: to_tz_time = from_tz_time.astimezone(tz) results[original_tz] = f"{to_tz_time.strftime('%Y-%m-%d %H:%M:%S')}" except Exception as e: return jsonify({"error": str(e)}), 400 return jsonify(results) @app.route("/og_image") def og_image(): from_tz = request.args.get('from_tz', 'Unknown') time = request.args.get('time', 'Unknown') to_tzs = request.args.get('to_tzs', 'Unknown') # Load the background image img_path = "templates/assets/img/og_image.webp" img = Image.open(img_path).convert("RGBA") d = ImageDraw.Draw(img) # Convert time format to 12-hour format and add AM/PM try: input_time = datetime.strptime(time, "%Y-%m-%dT%H:%M:%S") except ValueError: input_time = datetime.strptime(time, "%Y-%m-%dT%H:%M") time = input_time.strftime("%I:%M %p") # Prepare text text = f"{time} {from_tz.replace("_","/")}" from_tz = get_full_timezone(from_tz.replace("_", "/")) # Check if multiple timezones are provided if "," in to_tzs: to_tzs = to_tzs.split(",") to_tz_list = [(tz, get_full_timezone(tz.replace("_", "/"))) for tz in to_tzs if len(tz) > 0] else: to_tz_list = [(to_tzs, get_full_timezone(to_tzs.replace("_", "/")))] if from_tz is None or any(tz[1] is None for tz in to_tz_list): return send_from_directory("templates/assets/img", "og_image.webp") for tz in to_tz_list: to_tz = tz[1] to_tz_time = from_tz.localize(input_time).astimezone(to_tz) text += f"\n{to_tz_time.strftime('%I:%M %p')} {tz[0].replace('_','/')}" # Use font from assets font_path = "templates/assets/fonts/Orbitron-VariableFont_wght.ttf" font_size = 180 # Shrink font size if text is too long if (max(len(line) for line in text.split("\n")) > 13): font_size = 150 font_size -= (len(text.split("\n")) - 4) * 10 font = ImageFont.truetype(font_path, font_size) text_bbox = d.textbbox((0, 0), text, font=font) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] # Calculate text position x = (img.width - text_width) / 2 y = (img.height - text_height) / 2 # Add text to the image d.text((x, y), text, fill=(255, 255, 255), font=font, align="center") # Save the image to a BytesIO object in WebP format img_io = io.BytesIO() img.save(img_io, 'WEBP') img_io.seek(0) return Response(img_io, mimetype='image/webp') @app.route("/<path:path>") def catch_all(path: str): if os.path.isfile("templates/" + path): return render_template(path) # Try with .html if os.path.isfile("templates/" + path + ".html"): return render_template(path + ".html") if os.path.isfile("templates/" + path.strip("/") + ".html"): return render_template(path.strip("/") + ".html") # Try to find a file matching if path.count("/") < 1: # Try to find a file matching filename = find(path, "templates") if filename: return send_file(filename) return render_template("404.html"), 404 # endregion # region Error Catching # 404 catch all @app.errorhandler(404) def not_found(e): return render_template("404.html"), 404 # endregion if __name__ == "__main__": app.run(debug=True, port=5000, host="0.0.0.0")