added more tools and bug fixes
@@ -0,0 +1,79 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **Your Everyday Tools** are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project loosely follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.4.1] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **PDF Compress** no longer breaks page layout. Compressed images are now replaced in-place via `page.replace_image(xref, ...)` instead of being re-inserted at full page dimensions, so the original placement, size, and positioning matrix are preserved. Images shared across multiple pages are also deduplicated so they're only recompressed once.
|
||||
- **PDF Resize** now actually scales page content instead of only changing the media box (which previously just cropped the visible area, leaving images and text clipped or misaligned). Scale mode renders each page onto a new page at the new size; paper-size mode fits content into the target with aspect ratio preserved and orientation matched.
|
||||
- **Upload UI** — the invisible file input no longer covers the uploaded file list. The remove buttons on each listed file are now clickable. Structural fix: `.file-list` is now a sibling of `.upload-zone` rather than a child.
|
||||
|
||||
### Added
|
||||
- Per-quality image-dimension cap on PDF Compress (1200/1800/2400 px max edge for low/medium/high) so photo-heavy PDFs actually shrink.
|
||||
|
||||
## [0.4.0] — 2026-04-20
|
||||
|
||||
### Added — Developer Utilities (new category, 10 tools)
|
||||
- **UUID Generator** (client-side) — v4 UUIDs, bulk generation, formatting options.
|
||||
- **JWT Decoder** (client-side) — decodes JWT header/payload/signature; does not verify signatures.
|
||||
- **User-Agent Parser** (client-side) — extracts browser, OS, device, and engine.
|
||||
- **SQL Formatter** — pretty-prints SQL via `sqlparse` with configurable keyword case and indent.
|
||||
- **XML Formatter** (client-side) — format, validate, and minify XML via DOMParser.
|
||||
- **HTML Formatter** (client-side) — beautify/minify with correct handling of void tags, inline tags, and raw-content blocks.
|
||||
- **CSS Formatter** (client-side) — indent-aware beautify/minify.
|
||||
- **JS Formatter** (client-side) — basic beautify/minify with string and comment protection.
|
||||
- **Cron Parser** — validates expressions and previews upcoming runs via `croniter`.
|
||||
- **JSONPath Tester** — evaluates JSONPath expressions via `jsonpath-ng`.
|
||||
|
||||
### Added — Archive Tools (new category, 3 tools)
|
||||
- **Create ZIP** — bundle multiple files with Deflate or Store compression.
|
||||
- **Extract ZIP** — extract archive contents (500 MB cap; encrypted ZIPs rejected).
|
||||
- **ZIP Info** — list entries with sizes, dates, and overall compression ratio.
|
||||
|
||||
### Added — Audio & Video (new category, 6 tools — requires FFmpeg on PATH)
|
||||
- **Convert Audio** — MP3/WAV/OGG/FLAC/AAC/M4A/Opus with adjustable bitrate.
|
||||
- **Convert Video** — MP4/WebM/MKV/MOV/AVI with sensible codec defaults.
|
||||
- **Extract Audio** — pull audio track from a video file.
|
||||
- **Trim Media** — cut by start/end time; stream-copy first, re-encodes on failure.
|
||||
- **Compress Video** — H.264 re-encode at configurable CRF and preset.
|
||||
- **Video to GIF** — with FPS, width, start, and duration options.
|
||||
|
||||
### Added — Security
|
||||
- **File Hash** — streaming MD5/SHA-1/SHA-256/SHA-512 of uploaded files.
|
||||
|
||||
### Added — Dependencies
|
||||
- `sqlparse`, `croniter`, `jsonpath-ng` added to `requirements.txt`.
|
||||
- FFmpeg documented as an optional external binary; each media tool page shows install instructions and a detected/not-detected banner.
|
||||
|
||||
## [0.3.0] — 2026-04-19
|
||||
|
||||
### Added — Spreadsheet (new category, 6 tools)
|
||||
- **Excel to CSV / JSON** — export sheets from `.xlsx` / `.xls` to CSV or JSON (array-of-objects or array-of-arrays), single sheet or all sheets as ZIP.
|
||||
- **CSV / JSON to Excel** — build `.xlsx` from CSV/JSON files, one sheet per file, optional bold/shaded header row.
|
||||
- **Excel to PDF** — one section per sheet, configurable page size, orientation, and font size. 5000-row cap per sheet.
|
||||
- **Merge Workbooks** — combine multiple Excel files, optionally prefixing sheet names with source filename.
|
||||
- **Split Sheets** — export each sheet as its own `.xlsx`.
|
||||
- **Excel Info & Preview** — list sheet names, row/column counts, and preview rows.
|
||||
|
||||
### Added — Dependencies
|
||||
- `openpyxl` (required) and `xlrd` (for legacy `.xls` read) added to `requirements.txt`.
|
||||
|
||||
## [0.2.0] — 2026-04-19
|
||||
|
||||
### Added
|
||||
- **OCR PDF** — make scanned PDFs searchable (image + hidden text layer) or extract text. 14 languages supported via optional `pytesseract`.
|
||||
- **CAD to PDF / Image** — render DXF directly via `ezdxf` + `matplotlib`. DWG supported via optional **ODA File Converter** (auto-detected on PATH). Full install guide added to README and to the CAD tool page.
|
||||
- **Animated WebP / GIF** — convert between the two formats, preserving per-frame durations.
|
||||
|
||||
### Removed
|
||||
- MIT license badge from README (project has no license).
|
||||
|
||||
## [0.1.0] — 2026-04-18
|
||||
|
||||
### Added
|
||||
- Initial release — 48 tools across 7 categories: Document Conversion, PDF Tools, Image Tools, Text & Data, Calculators, QR Code, Security.
|
||||
- One universal upload template (`upload_tool.html`) powering all server-side tools.
|
||||
- Client-side-only tools for text utilities, calculators, password and hash generators.
|
||||
- Graceful degradation for heavy optional dependencies (`rembg`, `pyzbar`, `pdf2docx`).
|
||||
- Screenshots, README, `.gitignore`.
|
||||
@@ -1,10 +1,12 @@
|
||||
# Your Everyday Tools
|
||||
|
||||
A lightweight, self-hosted web app that bundles 57 everyday utilities into a single interface. Built with Python + Flask, zero JavaScript frameworks, and minimal CSS — no bloat, just tools.
|
||||
A lightweight, self-hosted web app that bundles 77 everyday utilities into a single interface. Built with Python + Flask, zero JavaScript frameworks, and minimal CSS — no bloat, just tools.
|
||||
|
||||

|
||||

|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for release history and recent fixes.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -107,6 +109,38 @@ A lightweight, self-hosted web app that bundles 57 everyday utilities into a sin
|
||||
|------|-------------|
|
||||
| **Password Generator** | Generate strong random passwords with configurable length, character types, and entropy display |
|
||||
| **Hash Generator** | Generate MD5, SHA-1, SHA-256, and SHA-512 hashes from text |
|
||||
| **File Hash** | Compute MD5, SHA-1, SHA-256, and SHA-512 hashes of an uploaded file (streamed, no size cap beyond upload limit) |
|
||||
|
||||
### Developer Utilities
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| **UUID Generator** | Generate v4 UUIDs — single or bulk (up to 1000), with uppercase, brace, and no-dash formatting |
|
||||
| **JWT Decoder** | Decode JSON Web Tokens client-side to inspect header, payload, and claims (decode only — does not verify signatures) |
|
||||
| **User-Agent Parser** | Parse browser, OS, device, and engine from any User-Agent string |
|
||||
| **SQL Formatter** | Pretty-print SQL with configurable keyword casing (UPPER / lower / Capitalize) and indentation — powered by `sqlparse` |
|
||||
| **XML Formatter** | Format, validate, and minify XML using the browser's native DOMParser |
|
||||
| **HTML Formatter** | Beautify or minify HTML source (void tags, inline tags, and `<script>` / `<style>` content handled correctly) |
|
||||
| **CSS Formatter** | Beautify or minify CSS rules with indent-aware output |
|
||||
| **JS Formatter** | Basic JavaScript beautifier and minifier (for complex code, use Prettier) |
|
||||
| **Cron Parser** | Validate cron expressions, see next upcoming run times, and get a field-by-field breakdown |
|
||||
| **JSONPath Tester** | Evaluate JSONPath expressions against JSON data — supports extended syntax via `jsonpath-ng` |
|
||||
|
||||
### Archive Tools
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| **Create ZIP** | Bundle multiple files into a single `.zip`, choose Deflate or Store compression |
|
||||
| **Extract ZIP** | Extract the contents of a `.zip` and re-download them (encrypted ZIPs not supported; 500 MB total cap) |
|
||||
| **ZIP Info** | List all entries in a `.zip` with uncompressed/compressed sizes, modified date, and overall compression ratio |
|
||||
|
||||
### Audio & Video (requires `ffmpeg` on PATH)
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| **Convert Audio** | Convert between MP3, WAV, OGG, FLAC, AAC, M4A, and Opus with adjustable bitrate |
|
||||
| **Convert Video** | Convert between MP4, WebM, MKV, MOV, and AVI (uses sensible codec defaults per target) |
|
||||
| **Extract Audio** | Pull the audio track out of a video file to MP3 / WAV / OGG / M4A |
|
||||
| **Trim Media** | Trim audio or video by start/end time (stream-copy first, re-encodes on keyframe mismatch) |
|
||||
| **Compress Video** | Re-encode video with H.264 at a chosen CRF and preset to shrink file size |
|
||||
| **Video to GIF** | Convert a clip to an animated GIF with configurable FPS, width, start, and duration |
|
||||
|
||||
---
|
||||
|
||||
@@ -153,6 +187,8 @@ The core app works out of the box with the main dependencies. Some features requ
|
||||
| `pdf2docx` | PDF to Word | Pure Python, but conversion quality depends on PDF complexity. |
|
||||
| `pytesseract` | Image to Text (OCR), OCR PDF | Requires the [Tesseract](https://github.com/tesseract-ocr/tesseract) binary installed on your system. For non-English OCR, download the matching `*.traineddata` language pack into your Tesseract `tessdata` folder. |
|
||||
| `ezdxf` + `matplotlib` | CAD to PDF/Image | Renders DXF drawings. For DWG support, also install the free [ODA File Converter](https://www.opendesign.com/guestfiles/oda_file_converter) and make sure it's on your `PATH`. |
|
||||
| `ffmpeg` (external) | All Audio & Video tools | Requires the [FFmpeg](https://ffmpeg.org/download.html) binary on your `PATH`. Each media tool page shows a green banner if FFmpeg is detected, with install instructions if not. |
|
||||
| `sqlparse`, `croniter`, `jsonpath-ng` | SQL Formatter, Cron Parser, JSONPath Tester | Small pure-Python packages included in `requirements.txt`. Everything else under *Developer Utilities* runs entirely in the browser. |
|
||||
|
||||
If you only need the core tools, install the minimal set:
|
||||
|
||||
@@ -209,8 +245,11 @@ your-everyday-tools/
|
||||
│ ├── text_tools.py # Text & data tool page routes
|
||||
│ ├── calculator_tools.py # Calculator page routes
|
||||
│ ├── qr_tools.py # QR code endpoints
|
||||
│ ├── security_tools.py # Security tool page routes
|
||||
│ └── spreadsheet_tools.py # Excel / CSV / JSON workbook tools
|
||||
│ ├── security_tools.py # Security tool page routes + file hash
|
||||
│ ├── spreadsheet_tools.py # Excel / CSV / JSON workbook tools
|
||||
│ ├── dev_tools.py # Developer utilities (UUID/JWT/UA/formatters/cron/jsonpath)
|
||||
│ ├── archive_tools.py # ZIP create / extract / info
|
||||
│ └── media_tools.py # FFmpeg-powered audio & video tools
|
||||
├── templates/
|
||||
│ ├── base.html # Main layout (sidebar + content area)
|
||||
│ ├── index.html # Home page with tool cards
|
||||
@@ -249,7 +288,7 @@ your-everyday-tools/
|
||||
- **Client-side tools** (text utilities, calculators, security tools) run entirely in the browser with vanilla JavaScript — zero server round-trips.
|
||||
- **In-memory processing** — all file operations use `BytesIO`. No temporary files are written to disk.
|
||||
- **No CSS framework** — custom CSS with CSS Grid, Flexbox, and CSS custom properties. The only external resource is Bootstrap Icons via CDN (~100 KB) for the icon set.
|
||||
- **Graceful degradation** — heavy optional packages (`rembg`, `pyzbar`, `pdf2docx`, `pytesseract`) are checked at import time. If missing, the affected tool shows a clear install instruction instead of crashing.
|
||||
- **Graceful degradation** — heavy optional packages (`rembg`, `pyzbar`, `pdf2docx`, `pytesseract`) and external binaries (ODA File Converter, FFmpeg) are probed at import time via `importlib` / `shutil.which`. If missing, the affected tool shows a clear install instruction instead of crashing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -116,6 +116,47 @@ TOOL_CATEGORIES = [
|
||||
"tools": [
|
||||
{"id": "password-generator", "name": "Password Generator", "desc": "Generate strong random passwords", "icon": "bi-key-fill"},
|
||||
{"id": "hash-generator", "name": "Hash Generator", "desc": "Generate MD5, SHA hashes", "icon": "bi-fingerprint"},
|
||||
{"id": "file-hash", "name": "File Hash", "desc": "Compute hashes of uploaded files", "icon": "bi-file-earmark-lock-fill"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Developer Utilities",
|
||||
"icon": "bi-code-slash",
|
||||
"tools": [
|
||||
{"id": "uuid", "name": "UUID Generator", "desc": "Generate v4 UUIDs (bulk supported)", "icon": "bi-hash"},
|
||||
{"id": "jwt", "name": "JWT Decoder", "desc": "Decode JWT tokens (client-side)", "icon": "bi-key"},
|
||||
{"id": "user-agent", "name": "User-Agent Parser", "desc": "Parse browser, OS, and device info", "icon": "bi-window"},
|
||||
{"id": "sql-format", "name": "SQL Formatter", "desc": "Pretty-print SQL with keyword casing", "icon": "bi-filetype-sql"},
|
||||
{"id": "xml-format", "name": "XML Formatter", "desc": "Format, validate, and minify XML", "icon": "bi-filetype-xml"},
|
||||
{"id": "html-format", "name": "HTML Formatter", "desc": "Beautify or minify HTML", "icon": "bi-filetype-html"},
|
||||
{"id": "css-format", "name": "CSS Formatter", "desc": "Beautify or minify CSS", "icon": "bi-filetype-css"},
|
||||
{"id": "js-format", "name": "JS Formatter", "desc": "Beautify or minify JavaScript", "icon": "bi-filetype-js"},
|
||||
{"id": "cron", "name": "Cron Parser", "desc": "Validate cron and preview runs", "icon": "bi-calendar-week-fill"},
|
||||
{"id": "jsonpath", "name": "JSONPath Tester", "desc": "Query JSON with JSONPath expressions", "icon": "bi-search"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "archive",
|
||||
"name": "Archive Tools",
|
||||
"icon": "bi-file-zip-fill",
|
||||
"tools": [
|
||||
{"id": "zip", "name": "Create ZIP", "desc": "Bundle multiple files into a .zip", "icon": "bi-file-zip"},
|
||||
{"id": "unzip", "name": "Extract ZIP", "desc": "Extract contents of a .zip archive", "icon": "bi-box-arrow-up"},
|
||||
{"id": "zip-info", "name": "ZIP Info", "desc": "Inspect archive contents and sizes", "icon": "bi-info-circle-fill"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "media",
|
||||
"name": "Audio & Video",
|
||||
"icon": "bi-camera-reels-fill",
|
||||
"tools": [
|
||||
{"id": "convert-audio", "name": "Convert Audio", "desc": "Change between audio formats", "icon": "bi-music-note-beamed"},
|
||||
{"id": "convert-video", "name": "Convert Video", "desc": "Change between video formats", "icon": "bi-camera-video-fill"},
|
||||
{"id": "extract-audio", "name": "Extract Audio", "desc": "Pull audio track from a video", "icon": "bi-mic-fill"},
|
||||
{"id": "trim", "name": "Trim Media", "desc": "Cut audio or video by time range", "icon": "bi-scissors"},
|
||||
{"id": "compress-video", "name": "Compress Video", "desc": "Re-encode to a smaller file", "icon": "bi-file-zip-fill"},
|
||||
{"id": "video-to-gif", "name": "Video to GIF", "desc": "Convert clips to animated GIFs", "icon": "bi-file-earmark-play-fill"},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -150,6 +191,9 @@ from routes.calculator_tools import bp as calc_bp
|
||||
from routes.qr_tools import bp as qr_bp
|
||||
from routes.security_tools import bp as security_bp
|
||||
from routes.spreadsheet_tools import bp as spreadsheet_bp
|
||||
from routes.dev_tools import bp as dev_bp
|
||||
from routes.archive_tools import bp as archive_bp
|
||||
from routes.media_tools import bp as media_bp
|
||||
|
||||
app.register_blueprint(convert_bp, url_prefix="/convert")
|
||||
app.register_blueprint(pdf_bp, url_prefix="/pdf")
|
||||
@@ -159,6 +203,9 @@ app.register_blueprint(calc_bp, url_prefix="/calc")
|
||||
app.register_blueprint(qr_bp, url_prefix="/qr")
|
||||
app.register_blueprint(security_bp, url_prefix="/security")
|
||||
app.register_blueprint(spreadsheet_bp, url_prefix="/spreadsheet")
|
||||
app.register_blueprint(dev_bp, url_prefix="/dev")
|
||||
app.register_blueprint(archive_bp, url_prefix="/archive")
|
||||
app.register_blueprint(media_bp, url_prefix="/media")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5000)
|
||||
|
||||
@@ -9,6 +9,9 @@ img2pdf
|
||||
python-docx
|
||||
openpyxl
|
||||
xlrd
|
||||
sqlparse
|
||||
croniter
|
||||
jsonpath-ng
|
||||
|
||||
# Optional (app works without these, shows install message if missing)
|
||||
rembg
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import io
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, send_file, jsonify
|
||||
|
||||
from utils.file_utils import make_zip
|
||||
|
||||
bp = Blueprint("archive", __name__)
|
||||
|
||||
MAX_ENTRIES = 2000
|
||||
MAX_EXTRACT_BYTES = 500 * 1024 * 1024 # 500 MB total extracted size (zip bomb guard)
|
||||
|
||||
|
||||
@bp.route("/zip", methods=["GET", "POST"])
|
||||
def zip_create():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Create ZIP",
|
||||
description="Bundle multiple files into a single .zip archive.",
|
||||
endpoint="/archive/zip",
|
||||
accept="*",
|
||||
multiple=True,
|
||||
options=[
|
||||
{
|
||||
"name": "compression",
|
||||
"label": "Compression",
|
||||
"type": "select",
|
||||
"default": "deflated",
|
||||
"choices": [
|
||||
{"value": "deflated", "label": "Deflate (smaller)"},
|
||||
{"value": "stored", "label": "Store (no compression)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "archive_name",
|
||||
"label": "Output name (without .zip)",
|
||||
"type": "text",
|
||||
"default": "archive",
|
||||
},
|
||||
],
|
||||
button_text="Create ZIP",
|
||||
)
|
||||
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
return jsonify({"error": "No files uploaded."}), 400
|
||||
|
||||
method = zipfile.ZIP_DEFLATED if request.form.get("compression", "deflated") == "deflated" else zipfile.ZIP_STORED
|
||||
name = (request.form.get("archive_name") or "archive").strip() or "archive"
|
||||
if name.lower().endswith(".zip"):
|
||||
name = name[:-4]
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", method) as zf:
|
||||
for f in files:
|
||||
data = f.read()
|
||||
zf.writestr(f.filename, data)
|
||||
buf.seek(0)
|
||||
|
||||
return send_file(buf, mimetype="application/zip", as_attachment=True, download_name=f"{name}.zip")
|
||||
|
||||
|
||||
@bp.route("/unzip", methods=["GET", "POST"])
|
||||
def zip_extract():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Extract ZIP",
|
||||
description="Extract a .zip archive and repackage the contents as a flat download.",
|
||||
notes="<strong>Note:</strong> extracted contents are returned as a new ZIP (preserving directory layout). "
|
||||
"Encrypted archives are not supported. Max total extracted size: 500 MB.",
|
||||
endpoint="/archive/unzip",
|
||||
accept=".zip",
|
||||
multiple=False,
|
||||
button_text="Extract",
|
||||
)
|
||||
|
||||
if "files" not in request.files:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
|
||||
f = request.files["files"]
|
||||
if not f.filename.lower().endswith(".zip"):
|
||||
return jsonify({"error": "Please upload a .zip file."}), 400
|
||||
|
||||
try:
|
||||
data = f.read()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
total = 0
|
||||
out = []
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
if info.file_size > MAX_EXTRACT_BYTES:
|
||||
return jsonify({"error": "File in archive exceeds size limit."}), 400
|
||||
total += info.file_size
|
||||
if total > MAX_EXTRACT_BYTES:
|
||||
return jsonify({"error": "Total extracted size exceeds 500 MB limit."}), 400
|
||||
out.append((info.filename, zf.read(info)))
|
||||
except zipfile.BadZipFile:
|
||||
return jsonify({"error": "Not a valid ZIP archive."}), 400
|
||||
except RuntimeError as e:
|
||||
if "password" in str(e).lower() or "encrypted" in str(e).lower():
|
||||
return jsonify({"error": "Password-protected ZIPs are not supported."}), 400
|
||||
return jsonify({"error": f"Extraction failed: {e}"}), 400
|
||||
|
||||
if not out:
|
||||
return jsonify({"error": "Archive is empty."}), 400
|
||||
|
||||
buf = make_zip(out)
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(buf, mimetype="application/zip", as_attachment=True, download_name=f"{base}_extracted.zip")
|
||||
|
||||
|
||||
@bp.route("/zip-info", methods=["GET", "POST"])
|
||||
def zip_info():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="ZIP Info",
|
||||
description="Inspect a ZIP archive: list files, sizes, and compression ratios.",
|
||||
endpoint="/archive/zip-info",
|
||||
accept=".zip",
|
||||
multiple=False,
|
||||
button_text="Inspect",
|
||||
)
|
||||
|
||||
if "files" not in request.files:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
|
||||
f = request.files["files"]
|
||||
if not f.filename.lower().endswith(".zip"):
|
||||
return jsonify({"error": "Please upload a .zip file."}), 400
|
||||
|
||||
try:
|
||||
data = f.read()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
entries = []
|
||||
total_uncompressed = 0
|
||||
total_compressed = 0
|
||||
for info in zf.infolist()[:MAX_ENTRIES]:
|
||||
dt = "-"
|
||||
try:
|
||||
dt = datetime(*info.date_time).strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
pass
|
||||
entries.append(
|
||||
f"{_format_size(info.file_size):>10} "
|
||||
f"{_format_size(info.compress_size):>10} "
|
||||
f"{dt} "
|
||||
f"{info.filename}"
|
||||
)
|
||||
total_uncompressed += info.file_size
|
||||
total_compressed += info.compress_size
|
||||
|
||||
ratio = (1 - total_compressed / total_uncompressed) * 100 if total_uncompressed else 0
|
||||
header = (
|
||||
f"{'Uncompr.':>10} {'Compr.':>10} {'Modified':<16} Name\n"
|
||||
f"{'-' * 10} {'-' * 10} {'-' * 16} {'-' * 30}"
|
||||
)
|
||||
footer = (
|
||||
f"\n{'-' * 10} {'-' * 10}\n"
|
||||
f"{_format_size(total_uncompressed):>10} {_format_size(total_compressed):>10} "
|
||||
f"({len(zf.infolist())} entries, {ratio:.1f}% saved)"
|
||||
)
|
||||
return jsonify({"text": header + "\n" + "\n".join(entries) + footer})
|
||||
except zipfile.BadZipFile:
|
||||
return jsonify({"error": "Not a valid ZIP archive."}), 400
|
||||
|
||||
|
||||
def _format_size(n: int) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
@@ -0,0 +1,212 @@
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
try:
|
||||
import sqlparse
|
||||
HAS_SQLPARSE = True
|
||||
except ImportError:
|
||||
HAS_SQLPARSE = False
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
HAS_CRONITER = True
|
||||
except ImportError:
|
||||
HAS_CRONITER = False
|
||||
|
||||
try:
|
||||
from jsonpath_ng.ext import parse as jsonpath_parse
|
||||
HAS_JSONPATH = True
|
||||
except ImportError:
|
||||
try:
|
||||
from jsonpath_ng import parse as jsonpath_parse
|
||||
HAS_JSONPATH = True
|
||||
except ImportError:
|
||||
HAS_JSONPATH = False
|
||||
|
||||
import json as _json
|
||||
|
||||
bp = Blueprint("dev", __name__)
|
||||
|
||||
|
||||
# ── Client-only pages ──────────────────────────────────
|
||||
|
||||
@bp.route("/uuid")
|
||||
def uuid_generator():
|
||||
return render_template("tools/uuid_generator.html")
|
||||
|
||||
|
||||
@bp.route("/jwt")
|
||||
def jwt_decoder():
|
||||
return render_template("tools/jwt_decoder.html")
|
||||
|
||||
|
||||
@bp.route("/user-agent")
|
||||
def user_agent_parser():
|
||||
return render_template("tools/user_agent_parser.html")
|
||||
|
||||
|
||||
@bp.route("/xml-format")
|
||||
def xml_formatter():
|
||||
return render_template("tools/xml_formatter.html")
|
||||
|
||||
|
||||
@bp.route("/html-format")
|
||||
def html_formatter():
|
||||
return render_template("tools/html_formatter.html")
|
||||
|
||||
|
||||
@bp.route("/css-format")
|
||||
def css_formatter():
|
||||
return render_template("tools/css_formatter.html")
|
||||
|
||||
|
||||
@bp.route("/js-format")
|
||||
def js_formatter():
|
||||
return render_template("tools/js_formatter.html")
|
||||
|
||||
|
||||
# ── Server-side helpers ────────────────────────────────
|
||||
|
||||
@bp.route("/sql-format", methods=["GET", "POST"])
|
||||
def sql_format():
|
||||
if request.method == "GET":
|
||||
return render_template("tools/sql_formatter.html")
|
||||
|
||||
if not HAS_SQLPARSE:
|
||||
return jsonify({"error": "sqlparse is not installed on the server."}), 500
|
||||
|
||||
sql = request.form.get("sql", "").strip()
|
||||
if not sql:
|
||||
return jsonify({"error": "No SQL provided."}), 400
|
||||
|
||||
keyword_case = request.form.get("keyword_case", "upper")
|
||||
indent = request.form.get("indent", "2")
|
||||
|
||||
if keyword_case not in ("upper", "lower", "capitalize"):
|
||||
keyword_case = "upper"
|
||||
|
||||
if indent == "tab":
|
||||
indent_width = 1
|
||||
use_tab = True
|
||||
else:
|
||||
try:
|
||||
indent_width = int(indent)
|
||||
if indent_width not in (2, 4):
|
||||
indent_width = 2
|
||||
except ValueError:
|
||||
indent_width = 2
|
||||
use_tab = False
|
||||
|
||||
try:
|
||||
formatted = sqlparse.format(
|
||||
sql,
|
||||
keyword_case=keyword_case,
|
||||
identifier_case=None,
|
||||
reindent=True,
|
||||
indent_width=indent_width,
|
||||
strip_comments=False,
|
||||
)
|
||||
if use_tab:
|
||||
formatted = formatted.replace(" ", "\t")
|
||||
return jsonify({"text": formatted})
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Formatting failed: {e}"}), 400
|
||||
|
||||
|
||||
@bp.route("/cron", methods=["GET", "POST"])
|
||||
def cron_parser():
|
||||
if request.method == "GET":
|
||||
return render_template("tools/cron_parser.html")
|
||||
|
||||
if not HAS_CRONITER:
|
||||
return jsonify({"error": "croniter is not installed on the server."}), 500
|
||||
|
||||
expr = request.form.get("expr", "").strip()
|
||||
count_raw = request.form.get("count", "10")
|
||||
|
||||
if not expr:
|
||||
return jsonify({"error": "No expression provided."}), 400
|
||||
|
||||
try:
|
||||
count = max(1, min(50, int(count_raw)))
|
||||
except ValueError:
|
||||
count = 10
|
||||
|
||||
if not croniter.is_valid(expr):
|
||||
return jsonify({"error": "Invalid cron expression."}), 400
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
it = croniter(expr, now)
|
||||
next_times = []
|
||||
for _ in range(count):
|
||||
ts = it.get_next(datetime)
|
||||
next_times.append(ts.strftime("%Y-%m-%d %H:%M:%S UTC"))
|
||||
|
||||
description = _describe_cron(expr)
|
||||
return jsonify({"description": description, "next": next_times})
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Parse failed: {e}"}), 400
|
||||
|
||||
|
||||
def _describe_cron(expr: str) -> str:
|
||||
parts = expr.split()
|
||||
if len(parts) not in (5, 6):
|
||||
return expr
|
||||
|
||||
labels = ["Minute", "Hour", "Day of month", "Month", "Day of week"]
|
||||
if len(parts) == 6:
|
||||
labels = ["Second"] + labels
|
||||
|
||||
lines = [f"Expression: {expr}"]
|
||||
for label, val in zip(labels, parts):
|
||||
meaning = _field_meaning(val)
|
||||
lines.append(f"{label:<14} {val:<10} → {meaning}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _field_meaning(val: str) -> str:
|
||||
if val == "*":
|
||||
return "every value"
|
||||
if val.startswith("*/"):
|
||||
return f"every {val[2:]}"
|
||||
if "-" in val and "/" not in val:
|
||||
return f"range {val}"
|
||||
if "," in val:
|
||||
return f"list: {val}"
|
||||
if "/" in val:
|
||||
return f"stepped: {val}"
|
||||
return f"exactly {val}"
|
||||
|
||||
|
||||
@bp.route("/jsonpath", methods=["GET", "POST"])
|
||||
def jsonpath_tester():
|
||||
if request.method == "GET":
|
||||
return render_template("tools/jsonpath_tester.html")
|
||||
|
||||
if not HAS_JSONPATH:
|
||||
return jsonify({"error": "jsonpath-ng is not installed on the server."}), 500
|
||||
|
||||
raw = request.form.get("data", "").strip()
|
||||
path = request.form.get("path", "").strip()
|
||||
|
||||
if not raw:
|
||||
return jsonify({"error": "No JSON data provided."}), 400
|
||||
if not path:
|
||||
return jsonify({"error": "No JSONPath expression provided."}), 400
|
||||
|
||||
try:
|
||||
data = _json.loads(raw)
|
||||
except _json.JSONDecodeError as e:
|
||||
return jsonify({"error": f"Invalid JSON: {e}"}), 400
|
||||
|
||||
try:
|
||||
expr = jsonpath_parse(path)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Invalid JSONPath: {e}"}), 400
|
||||
|
||||
try:
|
||||
matches = [m.value for m in expr.find(data)]
|
||||
return jsonify({"count": len(matches), "matches": matches})
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Evaluation failed: {e}"}), 400
|
||||
@@ -0,0 +1,474 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from flask import Blueprint, render_template, request, send_file, jsonify
|
||||
|
||||
bp = Blueprint("media", __name__)
|
||||
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
FFPROBE = shutil.which("ffprobe")
|
||||
|
||||
AUDIO_FORMATS = ["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"]
|
||||
VIDEO_FORMATS = ["mp4", "webm", "mkv", "mov", "avi"]
|
||||
|
||||
FFMPEG_INSTALL_NOTE = (
|
||||
'<p><strong>FFmpeg is required for this tool.</strong></p>'
|
||||
'<details><summary>How to install FFmpeg</summary>'
|
||||
'<p><strong>Windows:</strong> Download from '
|
||||
'<a href="https://www.gyan.dev/ffmpeg/builds/" target="_blank">gyan.dev</a> or '
|
||||
'<a href="https://github.com/BtbN/FFmpeg-Builds/releases" target="_blank">BtbN builds</a>, '
|
||||
'extract, and add the <code>bin</code> folder to your PATH.</p>'
|
||||
'<p><strong>macOS:</strong> <code>brew install ffmpeg</code></p>'
|
||||
'<p><strong>Linux:</strong> <code>sudo apt install ffmpeg</code> (Debian/Ubuntu) '
|
||||
'or <code>sudo dnf install ffmpeg</code> (Fedora).</p>'
|
||||
'<p>Restart the server after installing so the new PATH is picked up.</p>'
|
||||
'</details>'
|
||||
)
|
||||
|
||||
|
||||
def _ffmpeg_available_notes():
|
||||
if FFMPEG:
|
||||
return (
|
||||
f'<p><i class="bi bi-check-circle-fill" style="color:#2ec4b6"></i> '
|
||||
f'<strong>FFmpeg detected:</strong> <code>{FFMPEG}</code></p>'
|
||||
)
|
||||
return (
|
||||
'<p><i class="bi bi-exclamation-triangle-fill" style="color:#ffb703"></i> '
|
||||
'<strong>FFmpeg was not found on PATH.</strong> This tool will not work until FFmpeg is installed.</p>'
|
||||
+ FFMPEG_INSTALL_NOTE
|
||||
)
|
||||
|
||||
|
||||
def _run_ffmpeg(args: list[str], timeout: int = 180):
|
||||
if not FFMPEG:
|
||||
return None, "FFmpeg is not installed or not on PATH."
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[FFMPEG, "-y", "-hide_banner", "-loglevel", "error"] + args,
|
||||
capture_output=True, timeout=timeout,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = proc.stderr.decode("utf-8", errors="replace") or "unknown error"
|
||||
return None, f"FFmpeg failed: {err[:500]}"
|
||||
return proc, None
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "FFmpeg timed out."
|
||||
|
||||
|
||||
def _save_upload(file_storage, tmpdir: str) -> str:
|
||||
path = os.path.join(tmpdir, "input_" + file_storage.filename.replace("/", "_").replace("\\", "_"))
|
||||
file_storage.save(path)
|
||||
return path
|
||||
|
||||
|
||||
# ── Audio convert ──────────────────────────────────────
|
||||
|
||||
@bp.route("/convert-audio", methods=["GET", "POST"])
|
||||
def convert_audio():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Convert Audio",
|
||||
description="Convert between common audio formats using FFmpeg.",
|
||||
notes=_ffmpeg_available_notes(),
|
||||
endpoint="/media/convert-audio",
|
||||
accept=".mp3,.wav,.ogg,.flac,.aac,.m4a,.opus,.wma",
|
||||
multiple=False,
|
||||
options=[
|
||||
{
|
||||
"name": "format",
|
||||
"label": "Target format",
|
||||
"type": "select",
|
||||
"default": "mp3",
|
||||
"choices": [{"value": f, "label": f.upper()} for f in AUDIO_FORMATS],
|
||||
},
|
||||
{
|
||||
"name": "bitrate",
|
||||
"label": "Bitrate",
|
||||
"type": "select",
|
||||
"default": "192k",
|
||||
"choices": [
|
||||
{"value": "96k", "label": "96 kbps"},
|
||||
{"value": "128k", "label": "128 kbps"},
|
||||
{"value": "192k", "label": "192 kbps"},
|
||||
{"value": "256k", "label": "256 kbps"},
|
||||
{"value": "320k", "label": "320 kbps"},
|
||||
],
|
||||
},
|
||||
],
|
||||
button_text="Convert",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
fmt = request.form.get("format", "mp3")
|
||||
if fmt not in AUDIO_FORMATS:
|
||||
return jsonify({"error": "Unsupported target format."}), 400
|
||||
bitrate = request.form.get("bitrate", "192k")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
in_path = _save_upload(f, tmp)
|
||||
out_path = os.path.join(tmp, f"output.{fmt}")
|
||||
|
||||
args = ["-i", in_path]
|
||||
if fmt == "wav" or fmt == "flac":
|
||||
args += [out_path]
|
||||
else:
|
||||
args += ["-b:a", bitrate, out_path]
|
||||
|
||||
_, err = _run_ffmpeg(args)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
with open(out_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(
|
||||
_bytes_io(data),
|
||||
mimetype=f"audio/{fmt}",
|
||||
as_attachment=True,
|
||||
download_name=f"{base}.{fmt}",
|
||||
)
|
||||
|
||||
|
||||
# ── Video convert ──────────────────────────────────────
|
||||
|
||||
@bp.route("/convert-video", methods=["GET", "POST"])
|
||||
def convert_video():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Convert Video",
|
||||
description="Convert between common video formats using FFmpeg.",
|
||||
notes=_ffmpeg_available_notes(),
|
||||
endpoint="/media/convert-video",
|
||||
accept=".mp4,.webm,.mkv,.mov,.avi,.flv,.wmv",
|
||||
multiple=False,
|
||||
options=[
|
||||
{
|
||||
"name": "format",
|
||||
"label": "Target format",
|
||||
"type": "select",
|
||||
"default": "mp4",
|
||||
"choices": [{"value": f, "label": f.upper()} for f in VIDEO_FORMATS],
|
||||
},
|
||||
],
|
||||
button_text="Convert",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
fmt = request.form.get("format", "mp4")
|
||||
if fmt not in VIDEO_FORMATS:
|
||||
return jsonify({"error": "Unsupported target format."}), 400
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
in_path = _save_upload(f, tmp)
|
||||
out_path = os.path.join(tmp, f"output.{fmt}")
|
||||
|
||||
args = ["-i", in_path]
|
||||
if fmt == "webm":
|
||||
args += ["-c:v", "libvpx-vp9", "-c:a", "libopus", out_path]
|
||||
elif fmt == "mp4":
|
||||
args += ["-c:v", "libx264", "-c:a", "aac", "-preset", "medium", out_path]
|
||||
else:
|
||||
args += [out_path]
|
||||
|
||||
_, err = _run_ffmpeg(args, timeout=600)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
with open(out_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(
|
||||
_bytes_io(data),
|
||||
mimetype=f"video/{fmt}",
|
||||
as_attachment=True,
|
||||
download_name=f"{base}.{fmt}",
|
||||
)
|
||||
|
||||
|
||||
# ── Extract audio from video ───────────────────────────
|
||||
|
||||
@bp.route("/extract-audio", methods=["GET", "POST"])
|
||||
def extract_audio():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Extract Audio",
|
||||
description="Extract the audio track from a video file.",
|
||||
notes=_ffmpeg_available_notes(),
|
||||
endpoint="/media/extract-audio",
|
||||
accept=".mp4,.webm,.mkv,.mov,.avi,.flv,.wmv",
|
||||
multiple=False,
|
||||
options=[
|
||||
{
|
||||
"name": "format",
|
||||
"label": "Audio format",
|
||||
"type": "select",
|
||||
"default": "mp3",
|
||||
"choices": [
|
||||
{"value": "mp3", "label": "MP3"},
|
||||
{"value": "wav", "label": "WAV"},
|
||||
{"value": "ogg", "label": "OGG"},
|
||||
{"value": "m4a", "label": "M4A (AAC)"},
|
||||
],
|
||||
},
|
||||
],
|
||||
button_text="Extract",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
fmt = request.form.get("format", "mp3")
|
||||
if fmt not in ("mp3", "wav", "ogg", "m4a"):
|
||||
return jsonify({"error": "Unsupported audio format."}), 400
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
in_path = _save_upload(f, tmp)
|
||||
out_path = os.path.join(tmp, f"output.{fmt}")
|
||||
|
||||
args = ["-i", in_path, "-vn"]
|
||||
if fmt == "mp3":
|
||||
args += ["-b:a", "192k"]
|
||||
elif fmt == "m4a":
|
||||
args += ["-c:a", "aac", "-b:a", "192k"]
|
||||
args += [out_path]
|
||||
|
||||
_, err = _run_ffmpeg(args)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
with open(out_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(
|
||||
_bytes_io(data),
|
||||
mimetype=f"audio/{fmt}",
|
||||
as_attachment=True,
|
||||
download_name=f"{base}.{fmt}",
|
||||
)
|
||||
|
||||
|
||||
# ── Trim media ─────────────────────────────────────────
|
||||
|
||||
@bp.route("/trim", methods=["GET", "POST"])
|
||||
def trim():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Trim Media",
|
||||
description="Trim an audio or video file by start and end time (HH:MM:SS or seconds).",
|
||||
notes=_ffmpeg_available_notes(),
|
||||
endpoint="/media/trim",
|
||||
accept=".mp4,.webm,.mkv,.mov,.avi,.mp3,.wav,.ogg,.flac,.m4a",
|
||||
multiple=False,
|
||||
options=[
|
||||
{
|
||||
"name": "start",
|
||||
"label": "Start (e.g. 0 or 00:00:05)",
|
||||
"type": "text",
|
||||
"default": "0",
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"label": "End (leave blank for end-of-file)",
|
||||
"type": "text",
|
||||
"default": "",
|
||||
},
|
||||
],
|
||||
button_text="Trim",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
|
||||
start = (request.form.get("start") or "0").strip()
|
||||
end = (request.form.get("end") or "").strip()
|
||||
ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else "mp4"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
in_path = _save_upload(f, tmp)
|
||||
out_path = os.path.join(tmp, f"output.{ext}")
|
||||
|
||||
args = ["-i", in_path, "-ss", start]
|
||||
if end:
|
||||
args += ["-to", end]
|
||||
args += ["-c", "copy", out_path]
|
||||
|
||||
_, err = _run_ffmpeg(args)
|
||||
if err:
|
||||
# Re-encode fallback if stream copy fails (e.g. keyframe issues)
|
||||
args = ["-i", in_path, "-ss", start]
|
||||
if end:
|
||||
args += ["-to", end]
|
||||
args += [out_path]
|
||||
_, err = _run_ffmpeg(args, timeout=600)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
with open(out_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(
|
||||
_bytes_io(data),
|
||||
as_attachment=True,
|
||||
download_name=f"{base}_trimmed.{ext}",
|
||||
)
|
||||
|
||||
|
||||
# ── Compress video ─────────────────────────────────────
|
||||
|
||||
@bp.route("/compress-video", methods=["GET", "POST"])
|
||||
def compress_video():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Compress Video",
|
||||
description="Reduce video file size by re-encoding with H.264 at a chosen quality level.",
|
||||
notes=_ffmpeg_available_notes(),
|
||||
endpoint="/media/compress-video",
|
||||
accept=".mp4,.webm,.mkv,.mov,.avi,.flv",
|
||||
multiple=False,
|
||||
options=[
|
||||
{
|
||||
"name": "quality",
|
||||
"label": "Quality (CRF)",
|
||||
"type": "select",
|
||||
"default": "28",
|
||||
"choices": [
|
||||
{"value": "23", "label": "High (23 – larger, better)"},
|
||||
{"value": "28", "label": "Medium (28 – balanced)"},
|
||||
{"value": "32", "label": "Low (32 – smaller)"},
|
||||
{"value": "36", "label": "Very low (36 – smallest)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "preset",
|
||||
"label": "Encoding preset",
|
||||
"type": "select",
|
||||
"default": "medium",
|
||||
"choices": [
|
||||
{"value": "ultrafast", "label": "Ultrafast (largest)"},
|
||||
{"value": "fast", "label": "Fast"},
|
||||
{"value": "medium", "label": "Medium"},
|
||||
{"value": "slow", "label": "Slow (smallest)"},
|
||||
],
|
||||
},
|
||||
],
|
||||
button_text="Compress",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
|
||||
crf = request.form.get("quality", "28")
|
||||
preset = request.form.get("preset", "medium")
|
||||
if crf not in ("23", "28", "32", "36"):
|
||||
crf = "28"
|
||||
if preset not in ("ultrafast", "fast", "medium", "slow"):
|
||||
preset = "medium"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
in_path = _save_upload(f, tmp)
|
||||
out_path = os.path.join(tmp, "output.mp4")
|
||||
|
||||
args = ["-i", in_path, "-c:v", "libx264", "-crf", crf, "-preset", preset, "-c:a", "aac", "-b:a", "128k", out_path]
|
||||
|
||||
_, err = _run_ffmpeg(args, timeout=900)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
with open(out_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(
|
||||
_bytes_io(data),
|
||||
mimetype="video/mp4",
|
||||
as_attachment=True,
|
||||
download_name=f"{base}_compressed.mp4",
|
||||
)
|
||||
|
||||
|
||||
# ── Video to GIF ───────────────────────────────────────
|
||||
|
||||
@bp.route("/video-to-gif", methods=["GET", "POST"])
|
||||
def video_to_gif():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="Video to GIF",
|
||||
description="Convert a short video clip into an animated GIF.",
|
||||
notes=_ffmpeg_available_notes()
|
||||
+ "<p><strong>Tip:</strong> keep clips under ~10 seconds — GIFs are large.</p>",
|
||||
endpoint="/media/video-to-gif",
|
||||
accept=".mp4,.webm,.mkv,.mov,.avi",
|
||||
multiple=False,
|
||||
options=[
|
||||
{"name": "fps", "label": "FPS", "type": "number", "default": 15, "min": 1, "max": 30},
|
||||
{"name": "width", "label": "Width (px)", "type": "number", "default": 480, "min": 100, "max": 1920},
|
||||
{"name": "start", "label": "Start (seconds or HH:MM:SS)", "type": "text", "default": "0"},
|
||||
{"name": "duration", "label": "Duration (seconds, blank for all)", "type": "text", "default": "5"},
|
||||
],
|
||||
button_text="Convert",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
|
||||
try:
|
||||
fps = max(1, min(30, int(request.form.get("fps", 15))))
|
||||
width = max(100, min(1920, int(request.form.get("width", 480))))
|
||||
except ValueError:
|
||||
return jsonify({"error": "FPS and width must be integers."}), 400
|
||||
|
||||
start = (request.form.get("start") or "0").strip()
|
||||
duration = (request.form.get("duration") or "").strip()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
in_path = _save_upload(f, tmp)
|
||||
out_path = os.path.join(tmp, "output.gif")
|
||||
|
||||
args = ["-ss", start]
|
||||
if duration:
|
||||
args += ["-t", duration]
|
||||
args += [
|
||||
"-i", in_path,
|
||||
"-vf", f"fps={fps},scale={width}:-1:flags=lanczos",
|
||||
out_path,
|
||||
]
|
||||
|
||||
_, err = _run_ffmpeg(args, timeout=300)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
with open(out_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
base = f.filename.rsplit(".", 1)[0]
|
||||
return send_file(
|
||||
_bytes_io(data),
|
||||
mimetype="image/gif",
|
||||
as_attachment=True,
|
||||
download_name=f"{base}.gif",
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────
|
||||
|
||||
def _bytes_io(data: bytes):
|
||||
import io as _io
|
||||
return _io.BytesIO(data)
|
||||
@@ -268,26 +268,37 @@ def compress():
|
||||
|
||||
quality = request.form.get("quality", "medium")
|
||||
image_quality = {"low": 40, "medium": 65, "high": 85}.get(quality, 65)
|
||||
max_dim = {"low": 1200, "medium": 1800, "high": 2400}.get(quality, 1800)
|
||||
|
||||
from PIL import Image
|
||||
|
||||
doc = fitz.open(stream=files[0].read(), filetype="pdf")
|
||||
processed_xrefs = set()
|
||||
|
||||
for page in doc:
|
||||
images = page.get_images(full=True)
|
||||
for img_info in images:
|
||||
for img_info in page.get_images(full=True):
|
||||
xref = img_info[0]
|
||||
if xref in processed_xrefs:
|
||||
continue
|
||||
processed_xrefs.add(xref)
|
||||
try:
|
||||
base_image = doc.extract_image(xref)
|
||||
if not base_image:
|
||||
continue
|
||||
img_bytes = base_image["image"]
|
||||
from PIL import Image
|
||||
pil_img = Image.open(io.BytesIO(img_bytes))
|
||||
if pil_img.mode in ("RGBA", "P"):
|
||||
pil_img = Image.open(io.BytesIO(base_image["image"]))
|
||||
if pil_img.mode in ("RGBA", "LA", "P"):
|
||||
pil_img = pil_img.convert("RGB")
|
||||
elif pil_img.mode not in ("RGB", "L"):
|
||||
pil_img = pil_img.convert("RGB")
|
||||
|
||||
if max(pil_img.size) > max_dim:
|
||||
pil_img.thumbnail((max_dim, max_dim), Image.LANCZOS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
pil_img.save(buf, format="JPEG", quality=image_quality, optimize=True)
|
||||
doc._deleteObject(xref)
|
||||
page.insert_image(page.rect, stream=buf.getvalue())
|
||||
|
||||
# Replace image in-place — preserves original placement & size.
|
||||
page.replace_image(xref, stream=buf.getvalue())
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -334,21 +345,56 @@ def resize():
|
||||
|
||||
mode = request.form.get("mode", "scale")
|
||||
doc = fitz.open(stream=files[0].read(), filetype="pdf")
|
||||
new_doc = fitz.open()
|
||||
|
||||
if mode == "scale":
|
||||
scale = float(request.form.get("scale", 100)) / 100.0
|
||||
try:
|
||||
scale = float(request.form.get("scale", 100)) / 100.0
|
||||
except ValueError:
|
||||
scale = 1.0
|
||||
if scale <= 0:
|
||||
doc.close()
|
||||
new_doc.close()
|
||||
return jsonify(error="Scale must be greater than 0."), 400
|
||||
|
||||
for page in doc:
|
||||
r = page.rect
|
||||
new_rect = fitz.Rect(0, 0, r.width * scale, r.height * scale)
|
||||
page.set_mediabox(new_rect)
|
||||
new_w = r.width * scale
|
||||
new_h = r.height * scale
|
||||
new_page = new_doc.new_page(width=new_w, height=new_h)
|
||||
new_page.show_pdf_page(new_page.rect, doc, page.number,
|
||||
rotate=page.rotation)
|
||||
|
||||
elif mode == "paper":
|
||||
paper = request.form.get("paper", "a4")
|
||||
w, h = PAPER_SIZES.get(paper, PAPER_SIZES["a4"])
|
||||
target_w, target_h = PAPER_SIZES.get(paper, PAPER_SIZES["a4"])
|
||||
|
||||
for page in doc:
|
||||
page.set_mediabox(fitz.Rect(0, 0, w, h))
|
||||
r = page.rect
|
||||
src_w, src_h = r.width, r.height
|
||||
|
||||
# Match target orientation to source orientation
|
||||
if (src_w > src_h) != (target_w > target_h):
|
||||
page_w, page_h = target_h, target_w
|
||||
else:
|
||||
page_w, page_h = target_w, target_h
|
||||
|
||||
# Fit source page into new page, preserving aspect ratio
|
||||
fit = min(page_w / src_w, page_h / src_h)
|
||||
content_w = src_w * fit
|
||||
content_h = src_h * fit
|
||||
x0 = (page_w - content_w) / 2
|
||||
y0 = (page_h - content_h) / 2
|
||||
|
||||
new_page = new_doc.new_page(width=page_w, height=page_h)
|
||||
new_page.show_pdf_page(
|
||||
fitz.Rect(x0, y0, x0 + content_w, y0 + content_h),
|
||||
doc, page.number, rotate=page.rotation
|
||||
)
|
||||
|
||||
output = io.BytesIO()
|
||||
doc.save(output)
|
||||
new_doc.save(output, garbage=4, deflate=True)
|
||||
new_doc.close()
|
||||
doc.close()
|
||||
output.seek(0)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from flask import Blueprint, render_template
|
||||
import hashlib
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
bp = Blueprint("security", __name__)
|
||||
|
||||
HASH_ALGOS = ["md5", "sha1", "sha256", "sha512"]
|
||||
|
||||
|
||||
@bp.route("/password-generator")
|
||||
def password_generator():
|
||||
@@ -11,3 +14,38 @@ def password_generator():
|
||||
@bp.route("/hash-generator")
|
||||
def hash_generator():
|
||||
return render_template("tools/hash_generator.html")
|
||||
|
||||
|
||||
@bp.route("/file-hash", methods=["GET", "POST"])
|
||||
def file_hash():
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"upload_tool.html",
|
||||
title="File Hash",
|
||||
description="Compute MD5, SHA-1, SHA-256, and SHA-512 hashes of an uploaded file.",
|
||||
endpoint="/security/file-hash",
|
||||
accept="*",
|
||||
multiple=False,
|
||||
button_text="Compute",
|
||||
)
|
||||
|
||||
f = request.files.get("files")
|
||||
if not f:
|
||||
return jsonify({"error": "No file uploaded."}), 400
|
||||
|
||||
hashers = {name: hashlib.new(name) for name in HASH_ALGOS}
|
||||
total = 0
|
||||
chunk_size = 1024 * 1024 # 1 MB
|
||||
while True:
|
||||
chunk = f.stream.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
for h in hashers.values():
|
||||
h.update(chunk)
|
||||
|
||||
lines = [f"File: {f.filename}", f"Size: {total:,} bytes", ""]
|
||||
for name in HASH_ALGOS:
|
||||
lines.append(f"{name.upper():<8} {hashers[name].hexdigest()}")
|
||||
|
||||
return jsonify({"text": "\n".join(lines)})
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Cron Parser - EveryTools{% endblock %}
|
||||
{% block top_title %}Cron Parser{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>Cron Expression Parser</h1>
|
||||
<p>Validate cron expressions and preview upcoming run times</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:.6rem;flex-wrap:wrap;align-items:center">
|
||||
<label>Expression:
|
||||
<input type="text" id="cron-expr" value="*/5 * * * *" style="font-family:monospace;min-width:200px">
|
||||
</label>
|
||||
<label>Count:
|
||||
<input type="number" id="cron-count" value="10" min="1" max="50" style="width:70px">
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="parseCron()"><i class="bi bi-play-circle"></i> Parse</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:.6rem">
|
||||
<button class="btn btn-small" onclick="setExpr('* * * * *')">Every minute</button>
|
||||
<button class="btn btn-small" onclick="setExpr('*/5 * * * *')">Every 5 min</button>
|
||||
<button class="btn btn-small" onclick="setExpr('0 * * * *')">Hourly</button>
|
||||
<button class="btn btn-small" onclick="setExpr('0 0 * * *')">Daily midnight</button>
|
||||
<button class="btn btn-small" onclick="setExpr('0 9 * * 1-5')">Weekdays 9am</button>
|
||||
<button class="btn btn-small" onclick="setExpr('0 0 1 * *')">Monthly 1st</button>
|
||||
<button class="btn btn-small" onclick="setExpr('0 0 * * 0')">Weekly Sunday</button>
|
||||
</div>
|
||||
|
||||
<div id="cron-status" style="margin:.5rem 0;font-size:.9rem"></div>
|
||||
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header"><span>Description</span></div>
|
||||
<div class="pane-body"><pre id="cron-desc"></pre></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header"><span>Next runs</span></div>
|
||||
<div class="pane-body"><pre id="cron-next"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function setExpr(e) {
|
||||
document.getElementById("cron-expr").value = e;
|
||||
parseCron();
|
||||
}
|
||||
|
||||
async function parseCron() {
|
||||
const expr = document.getElementById("cron-expr").value;
|
||||
const count = document.getElementById("cron-count").value;
|
||||
const status = document.getElementById("cron-status");
|
||||
|
||||
if (!expr.trim()) { status.textContent = "Enter a cron expression."; return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("expr", expr);
|
||||
fd.append("count", count);
|
||||
|
||||
status.textContent = "Parsing...";
|
||||
try {
|
||||
const r = await fetch("/dev/cron", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (j.error) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${j.error}</span>`;
|
||||
document.getElementById("cron-desc").textContent = "";
|
||||
document.getElementById("cron-next").textContent = "";
|
||||
} else {
|
||||
status.innerHTML = '<span style="color:var(--success)"><i class="bi bi-check-circle"></i> Valid expression</span>';
|
||||
document.getElementById("cron-desc").textContent = j.description;
|
||||
document.getElementById("cron-next").textContent = j.next.join("\n");
|
||||
}
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)">Network error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
parseCron();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CSS Formatter - EveryTools{% endblock %}
|
||||
{% block top_title %}CSS Formatter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>CSS Formatter</h1>
|
||||
<p>Beautify or minify CSS</p>
|
||||
</div>
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Input</span>
|
||||
<div>
|
||||
<button class="btn btn-small" onclick="formatCSS()">Beautify</button>
|
||||
<button class="btn btn-small" onclick="minifyCSS()">Minify</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<textarea id="css-input" placeholder=".example { color: red; background: #fff; }"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Output</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<pre id="css-output"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function formatCSS() {
|
||||
let src = document.getElementById("css-input").value;
|
||||
src = src.replace(/\/\*[\s\S]*?\*\//g, m => "\u0000COMMENT" + btoa(unescape(encodeURIComponent(m))) + "\u0000");
|
||||
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let i = 0;
|
||||
let buf = "";
|
||||
const pad = () => " ".repeat(depth);
|
||||
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "{") {
|
||||
out.push(pad() + buf.trim() + " {");
|
||||
depth++;
|
||||
buf = "";
|
||||
} else if (ch === "}") {
|
||||
if (buf.trim()) out.push(pad() + buf.trim() + (buf.trim().endsWith(";") ? "" : ";"));
|
||||
depth = Math.max(0, depth - 1);
|
||||
out.push(pad() + "}");
|
||||
buf = "";
|
||||
} else if (ch === ";") {
|
||||
const line = buf.trim();
|
||||
if (line) out.push(pad() + line + ";");
|
||||
buf = "";
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (buf.trim()) out.push(pad() + buf.trim());
|
||||
|
||||
let result = out.join("\n").replace(/\u0000COMMENT([A-Za-z0-9+/=]+)\u0000/g, (_, b) => {
|
||||
try { return decodeURIComponent(escape(atob(b))); } catch (e) { return ""; }
|
||||
});
|
||||
document.getElementById("css-output").textContent = result;
|
||||
}
|
||||
|
||||
function minifyCSS() {
|
||||
const src = document.getElementById("css-input").value;
|
||||
const out = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\s*([{}:;,>~+])\s*/g, "$1")
|
||||
.replace(/;}/g, "}")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
document.getElementById("css-output").textContent = out;
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("css-output").textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}HTML Formatter - EveryTools{% endblock %}
|
||||
{% block top_title %}HTML Formatter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>HTML Formatter</h1>
|
||||
<p>Beautify or minify HTML source</p>
|
||||
</div>
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Input</span>
|
||||
<div>
|
||||
<button class="btn btn-small" onclick="formatHTML()">Beautify</button>
|
||||
<button class="btn btn-small" onclick="minifyHTML()">Minify</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<textarea id="html-input" placeholder="<div><p>Hello</p></div>"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Output</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<pre id="html-output"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const VOID_TAGS = new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]);
|
||||
const INLINE_TAGS = new Set(["a","abbr","b","bdo","br","cite","code","em","i","img","input","kbd","label","mark","q","s","small","span","strong","sub","sup","time","u","var"]);
|
||||
|
||||
function tokenize(src) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
if (src[i] === "<") {
|
||||
const end = src.indexOf(">", i);
|
||||
if (end === -1) { tokens.push({type: "text", value: src.slice(i)}); break; }
|
||||
const tag = src.slice(i, end + 1);
|
||||
if (tag.startsWith("<!--")) {
|
||||
const c = src.indexOf("-->", i);
|
||||
tokens.push({type: "comment", value: src.slice(i, c === -1 ? src.length : c + 3)});
|
||||
i = c === -1 ? src.length : c + 3;
|
||||
} else if (tag.startsWith("<!")) {
|
||||
tokens.push({type: "doctype", value: tag});
|
||||
i = end + 1;
|
||||
} else if (tag.startsWith("</")) {
|
||||
tokens.push({type: "close", value: tag, name: tag.slice(2, -1).trim().toLowerCase()});
|
||||
i = end + 1;
|
||||
} else {
|
||||
const name = tag.slice(1).split(/[\s>/]/)[0].toLowerCase();
|
||||
const selfClosing = tag.endsWith("/>") || VOID_TAGS.has(name);
|
||||
tokens.push({type: selfClosing ? "void" : "open", value: tag, name});
|
||||
i = end + 1;
|
||||
if (["script","style","pre","textarea"].includes(name) && !selfClosing) {
|
||||
const closeRe = new RegExp(`</${name}\\s*>`, "i");
|
||||
const m = src.slice(i).match(closeRe);
|
||||
if (m) {
|
||||
tokens.push({type: "raw", value: src.slice(i, i + m.index)});
|
||||
tokens.push({type: "close", value: m[0], name});
|
||||
i += m.index + m[0].length;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const next = src.indexOf("<", i);
|
||||
const text = src.slice(i, next === -1 ? src.length : next);
|
||||
if (text.trim()) tokens.push({type: "text", value: text.trim()});
|
||||
i = next === -1 ? src.length : next;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function formatHTML() {
|
||||
const src = document.getElementById("html-input").value;
|
||||
const tokens = tokenize(src);
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
const pad = () => " ".repeat(depth);
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
const next = tokens[i + 1];
|
||||
if (t.type === "open") {
|
||||
// Inline tag with just text inside, keep on one line
|
||||
if (INLINE_TAGS.has(t.name) && next && next.type === "text" &&
|
||||
tokens[i + 2] && tokens[i + 2].type === "close" && tokens[i + 2].name === t.name) {
|
||||
out.push(pad() + t.value + next.value + tokens[i + 2].value);
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(pad() + t.value);
|
||||
depth++;
|
||||
}
|
||||
} else if (t.type === "close") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
out.push(pad() + t.value);
|
||||
} else if (t.type === "void" || t.type === "doctype" || t.type === "comment") {
|
||||
out.push(pad() + t.value);
|
||||
} else if (t.type === "text") {
|
||||
out.push(pad() + t.value);
|
||||
} else if (t.type === "raw") {
|
||||
out.push(t.value);
|
||||
}
|
||||
}
|
||||
document.getElementById("html-output").textContent = out.join("\n");
|
||||
}
|
||||
|
||||
function minifyHTML() {
|
||||
const src = document.getElementById("html-input").value;
|
||||
const out = src
|
||||
.replace(/<!--[\s\S]*?-->/g, "")
|
||||
.replace(/>\s+</g, "><")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
document.getElementById("html-output").textContent = out;
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("html-output").textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JS Formatter - EveryTools{% endblock %}
|
||||
{% block top_title %}JS Formatter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>JavaScript Formatter</h1>
|
||||
<p>Basic beautification and minification. For complex code, use a full tool like Prettier.</p>
|
||||
</div>
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Input</span>
|
||||
<div>
|
||||
<button class="btn btn-small" onclick="formatJS()">Beautify</button>
|
||||
<button class="btn btn-small" onclick="minifyJS()">Minify</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<textarea id="js-input" placeholder="function hello(){console.log('hi');}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Output</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<pre id="js-output"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Stash strings, regex, and comments so we don't mangle them.
|
||||
function protect(src) {
|
||||
const stash = [];
|
||||
let i = 0;
|
||||
let out = "";
|
||||
const push = v => { const k = `\u0000__${stash.length}__\u0000`; stash.push(v); return k; };
|
||||
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === "/" && src[i+1] === "/") {
|
||||
const end = src.indexOf("\n", i);
|
||||
const seg = src.slice(i, end === -1 ? src.length : end);
|
||||
out += push(seg);
|
||||
i = end === -1 ? src.length : end;
|
||||
} else if (c === "/" && src[i+1] === "*") {
|
||||
const end = src.indexOf("*/", i);
|
||||
const seg = src.slice(i, end === -1 ? src.length : end + 2);
|
||||
out += push(seg);
|
||||
i = end === -1 ? src.length : end + 2;
|
||||
} else if (c === '"' || c === "'" || c === "`") {
|
||||
let j = i + 1;
|
||||
while (j < src.length && src[j] !== c) {
|
||||
if (src[j] === "\\") j += 2;
|
||||
else j++;
|
||||
}
|
||||
out += push(src.slice(i, j + 1));
|
||||
i = j + 1;
|
||||
} else {
|
||||
out += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return { code: out, stash };
|
||||
}
|
||||
|
||||
function restore(code, stash) {
|
||||
return code.replace(/\u0000__(\d+)__\u0000/g, (_, n) => stash[+n]);
|
||||
}
|
||||
|
||||
function formatJS() {
|
||||
const src = document.getElementById("js-input").value;
|
||||
const { code, stash } = protect(src);
|
||||
|
||||
let out = "";
|
||||
let depth = 0;
|
||||
let inParens = 0;
|
||||
const pad = () => " ".repeat(depth);
|
||||
let lineStarted = true;
|
||||
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const c = code[i];
|
||||
if (c === "{") {
|
||||
out = out.trimEnd() + " {\n";
|
||||
depth++;
|
||||
lineStarted = true;
|
||||
i = skipSpace(code, i + 1) - 1;
|
||||
} else if (c === "}") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
out = out.trimEnd() + "\n" + pad() + "}";
|
||||
lineStarted = false;
|
||||
// Newline after } unless followed by comma/semicolon/paren
|
||||
const nx = code[skipSpace(code, i + 1)];
|
||||
if (nx && ",;)]".indexOf(nx) === -1) { out += "\n"; lineStarted = true; }
|
||||
} else if (c === ";") {
|
||||
out += ";\n";
|
||||
lineStarted = true;
|
||||
i = skipSpace(code, i + 1) - 1;
|
||||
} else if (c === "(") {
|
||||
inParens++;
|
||||
out += c;
|
||||
} else if (c === ")") {
|
||||
inParens = Math.max(0, inParens - 1);
|
||||
out += c;
|
||||
} else if (c === "\n" || c === "\r") {
|
||||
if (!lineStarted) { out += "\n"; lineStarted = true; }
|
||||
} else if (/\s/.test(c)) {
|
||||
if (lineStarted) continue;
|
||||
out += " ";
|
||||
} else {
|
||||
if (lineStarted) { out += pad(); lineStarted = false; }
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
|
||||
out = out.replace(/\n{3,}/g, "\n\n");
|
||||
document.getElementById("js-output").textContent = restore(out, stash).trim();
|
||||
}
|
||||
|
||||
function skipSpace(s, i) {
|
||||
while (i < s.length && /\s/.test(s[i])) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
function minifyJS() {
|
||||
const src = document.getElementById("js-input").value;
|
||||
const { code, stash } = protect(src);
|
||||
const minified = code
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/\s*([{}();,:=<>+\-*/%!&|?])\s*/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
document.getElementById("js-output").textContent = restore(minified, stash);
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("js-output").textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JSONPath Tester - EveryTools{% endblock %}
|
||||
{% block top_title %}JSONPath Tester{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>JSONPath Tester</h1>
|
||||
<p>Query JSON data using JSONPath expressions</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:.6rem;flex-wrap:wrap;align-items:center">
|
||||
<label style="flex:1;min-width:240px">Path:
|
||||
<input type="text" id="jp-path" value="$.store.book[*].title" style="font-family:monospace;width:100%">
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="runPath()"><i class="bi bi-play-circle"></i> Evaluate</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:.6rem">
|
||||
<button class="btn btn-small" onclick="setExample()">Load example</button>
|
||||
<button class="btn btn-small" onclick="setPath('$..author')">All authors</button>
|
||||
<button class="btn btn-small" onclick="setPath('$.store.book[?(@.price < 10)]')">Cheap books</button>
|
||||
<button class="btn btn-small" onclick="setPath('$..book[0,1]')">First two books</button>
|
||||
<button class="btn btn-small" onclick="setPath('$..*')">Everything</button>
|
||||
</div>
|
||||
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header"><span>JSON Input</span></div>
|
||||
<div class="pane-body">
|
||||
<textarea id="jp-input" placeholder='{"key": "value"}'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Matches</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body"><pre id="jp-output"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="jp-status" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const EXAMPLE = {
|
||||
store: {
|
||||
book: [
|
||||
{category: "fiction", author: "J.R.R. Tolkien", title: "The Hobbit", price: 14.99},
|
||||
{category: "fiction", author: "George Orwell", title: "1984", price: 9.99},
|
||||
{category: "reference", author: "Nigel Rees", title: "Sayings of the Century", price: 8.95},
|
||||
{category: "fiction", author: "Evelyn Waugh", title: "Sword of Honour", price: 12.99}
|
||||
],
|
||||
bicycle: {color: "red", price: 19.95}
|
||||
}
|
||||
};
|
||||
|
||||
function setExample() {
|
||||
document.getElementById("jp-input").value = JSON.stringify(EXAMPLE, null, 2);
|
||||
}
|
||||
|
||||
function setPath(p) {
|
||||
document.getElementById("jp-path").value = p;
|
||||
runPath();
|
||||
}
|
||||
|
||||
async function runPath() {
|
||||
const data = document.getElementById("jp-input").value;
|
||||
const path = document.getElementById("jp-path").value;
|
||||
const status = document.getElementById("jp-status");
|
||||
|
||||
if (!data.trim()) { status.textContent = "Provide JSON data."; return; }
|
||||
if (!path.trim()) { status.textContent = "Provide a JSONPath expression."; return; }
|
||||
|
||||
try { JSON.parse(data); } catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> Invalid JSON: ${e.message}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("data", data);
|
||||
fd.append("path", path);
|
||||
|
||||
status.textContent = "Evaluating...";
|
||||
try {
|
||||
const r = await fetch("/dev/jsonpath", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (j.error) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${j.error}</span>`;
|
||||
document.getElementById("jp-output").textContent = "";
|
||||
} else {
|
||||
document.getElementById("jp-output").textContent = JSON.stringify(j.matches, null, 2);
|
||||
status.innerHTML = `<span style="color:var(--success)"><i class="bi bi-check-circle"></i> ${j.count} match${j.count === 1 ? "" : "es"}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)">Network error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("jp-output").textContent);
|
||||
}
|
||||
|
||||
setExample();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}JWT Decoder - EveryTools{% endblock %}
|
||||
{% block top_title %}JWT Decoder{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>JWT Decoder</h1>
|
||||
<p>Decode header and payload of a JSON Web Token. Signatures are NOT verified — do not trust decoded content without verification.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Paste JWT</label>
|
||||
<textarea id="jwt-input" class="text-input" rows="4"
|
||||
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
oninput="decode()"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="jwt-status" style="margin-bottom:1rem;font-size:.9rem"></div>
|
||||
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Header <span style="font-weight:400;color:var(--text-light)">(algorithm & type)</span></span>
|
||||
<button class="btn btn-small" onclick="copyField('jwt-header')"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
<div class="pane-body"><pre id="jwt-header"></pre></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Payload <span style="font-weight:400;color:var(--text-light)">(claims)</span></span>
|
||||
<button class="btn btn-small" onclick="copyField('jwt-payload')"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
<div class="pane-body"><pre id="jwt-payload"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane" style="margin-top:.8rem">
|
||||
<div class="pane-header">
|
||||
<span>Signature</span>
|
||||
<button class="btn btn-small" onclick="copyField('jwt-sig')"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
<div class="pane-body"><pre id="jwt-sig" style="word-break:break-all"></pre></div>
|
||||
</div>
|
||||
|
||||
<div id="jwt-claims" style="margin-top:1rem;font-size:.9rem;color:var(--text-light)"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function b64urlDecode(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (s.length % 4) s += "=";
|
||||
const bin = atob(s);
|
||||
const bytes = new Uint8Array([...bin].map(c => c.charCodeAt(0)));
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
function decode() {
|
||||
const val = document.getElementById("jwt-input").value.trim();
|
||||
const status = document.getElementById("jwt-status");
|
||||
const claims = document.getElementById("jwt-claims");
|
||||
["jwt-header","jwt-payload","jwt-sig"].forEach(i => document.getElementById(i).textContent = "");
|
||||
claims.innerHTML = "";
|
||||
|
||||
if (!val) { status.textContent = ""; return; }
|
||||
|
||||
const parts = val.split(".");
|
||||
if (parts.length !== 3) {
|
||||
status.innerHTML = '<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> Invalid JWT: expected 3 dot-separated parts</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const header = JSON.parse(b64urlDecode(parts[0]));
|
||||
const payload = JSON.parse(b64urlDecode(parts[1]));
|
||||
document.getElementById("jwt-header").textContent = JSON.stringify(header, null, 2);
|
||||
document.getElementById("jwt-payload").textContent = JSON.stringify(payload, null, 2);
|
||||
document.getElementById("jwt-sig").textContent = parts[2];
|
||||
|
||||
status.innerHTML = '<span style="color:var(--success)"><i class="bi bi-check-circle"></i> Decoded successfully (signature not verified)</span>';
|
||||
|
||||
const notes = [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp) {
|
||||
const d = new Date(payload.exp * 1000);
|
||||
const expired = payload.exp < now;
|
||||
notes.push(`<strong>exp:</strong> ${d.toLocaleString()} ${expired ? '<span style="color:var(--danger)">(EXPIRED)</span>' : '<span style="color:var(--success)">(valid)</span>'}`);
|
||||
}
|
||||
if (payload.iat) notes.push(`<strong>iat:</strong> ${new Date(payload.iat * 1000).toLocaleString()}`);
|
||||
if (payload.nbf) notes.push(`<strong>nbf:</strong> ${new Date(payload.nbf * 1000).toLocaleString()}`);
|
||||
if (payload.iss) notes.push(`<strong>iss:</strong> ${payload.iss}`);
|
||||
if (payload.aud) notes.push(`<strong>aud:</strong> ${Array.isArray(payload.aud) ? payload.aud.join(", ") : payload.aud}`);
|
||||
if (payload.sub) notes.push(`<strong>sub:</strong> ${payload.sub}`);
|
||||
if (notes.length) claims.innerHTML = notes.join(" · ");
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> Decode error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function copyField(id) {
|
||||
navigator.clipboard.writeText(document.getElementById(id).textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SQL Formatter - EveryTools{% endblock %}
|
||||
{% block top_title %}SQL Formatter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>SQL Formatter</h1>
|
||||
<p>Format SQL statements with proper indentation and keyword casing</p>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:.6rem;flex-wrap:wrap;align-items:center">
|
||||
<label>Keyword case:
|
||||
<select id="sql-keyword">
|
||||
<option value="upper" selected>UPPERCASE</option>
|
||||
<option value="lower">lowercase</option>
|
||||
<option value="capitalize">Capitalize</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Indent:
|
||||
<select id="sql-indent">
|
||||
<option value="2" selected>2 spaces</option>
|
||||
<option value="4">4 spaces</option>
|
||||
<option value="tab">tab</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="formatSQL()"><i class="bi bi-magic"></i> Format</button>
|
||||
</div>
|
||||
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header"><span>Input</span></div>
|
||||
<div class="pane-body">
|
||||
<textarea id="sql-input" placeholder="select * from users where id=1"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Output</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body"><pre id="sql-output"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sql-status" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function formatSQL() {
|
||||
const input = document.getElementById("sql-input").value;
|
||||
const status = document.getElementById("sql-status");
|
||||
if (!input.trim()) { status.textContent = "Enter some SQL."; return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("sql", input);
|
||||
fd.append("keyword_case", document.getElementById("sql-keyword").value);
|
||||
fd.append("indent", document.getElementById("sql-indent").value);
|
||||
|
||||
status.textContent = "Formatting...";
|
||||
try {
|
||||
const r = await fetch("/dev/sql-format", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (j.error) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${j.error}</span>`;
|
||||
} else {
|
||||
document.getElementById("sql-output").textContent = j.text;
|
||||
status.innerHTML = '<span style="color:var(--success)"><i class="bi bi-check-circle"></i> Formatted</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)">Network error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("sql-output").textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}User-Agent Parser - EveryTools{% endblock %}
|
||||
{% block top_title %}User-Agent Parser{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>User-Agent Parser</h1>
|
||||
<p>Extract browser, OS, and device info from a User-Agent string.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>User-Agent string <button class="btn btn-small" onclick="useMine()" style="margin-left:.5rem">Use mine</button></label>
|
||||
<textarea id="ua-input" class="text-input" rows="3"
|
||||
placeholder="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..."
|
||||
oninput="parseUA()"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="ua-results" style="display:grid;grid-template-columns:1fr 1fr;gap:.8rem"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function row(label, val) {
|
||||
return `<div style="padding:.35rem .6rem;border-bottom:1px solid var(--border)">
|
||||
<div style="font-size:.75rem;color:var(--text-light);text-transform:uppercase;letter-spacing:.03em">${label}</div>
|
||||
<div style="font-weight:500">${val || "—"}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function parseUA() {
|
||||
const ua = document.getElementById("ua-input").value.trim();
|
||||
const out = document.getElementById("ua-results");
|
||||
if (!ua) { out.innerHTML = ""; return; }
|
||||
|
||||
let browser = "Unknown", browserVer = "";
|
||||
let os = "Unknown", osVer = "";
|
||||
let device = "Desktop";
|
||||
let engine = "Unknown";
|
||||
|
||||
// Browser — order matters (more specific first)
|
||||
const b = [
|
||||
["Edg/", "Edge"],
|
||||
["OPR/|Opera/", "Opera"],
|
||||
["Firefox/", "Firefox"],
|
||||
["Chrome/", "Chrome"],
|
||||
["Safari/", "Safari"],
|
||||
["MSIE |Trident/", "Internet Explorer"],
|
||||
];
|
||||
for (const [pat, name] of b) {
|
||||
const m = ua.match(new RegExp(`(?:${pat})([\\d.]+)`));
|
||||
if (m) { browser = name; browserVer = m[1]; break; }
|
||||
}
|
||||
|
||||
// OS
|
||||
if (/Windows NT 10\.0/.test(ua)) { os = "Windows"; osVer = "10 / 11"; }
|
||||
else if (/Windows NT 6\.3/.test(ua)) { os = "Windows"; osVer = "8.1"; }
|
||||
else if (/Windows NT 6\.1/.test(ua)) { os = "Windows"; osVer = "7"; }
|
||||
else if (/Mac OS X ([\d_.]+)/.test(ua)) { os = "macOS"; osVer = RegExp.$1.replace(/_/g, "."); }
|
||||
else if (/Android ([\d.]+)/.test(ua)) { os = "Android"; osVer = RegExp.$1; device = "Mobile"; }
|
||||
else if (/iPhone OS ([\d_]+)/.test(ua)) { os = "iOS"; osVer = RegExp.$1.replace(/_/g, "."); device = "Mobile"; }
|
||||
else if (/iPad; CPU OS ([\d_]+)/.test(ua)) { os = "iPadOS"; osVer = RegExp.$1.replace(/_/g, "."); device = "Tablet"; }
|
||||
else if (/CrOS/.test(ua)) { os = "ChromeOS"; }
|
||||
else if (/Linux/.test(ua)) { os = "Linux"; }
|
||||
|
||||
// Device refine
|
||||
if (/Mobile|iPhone|Android.*Mobile/.test(ua)) device = "Mobile";
|
||||
else if (/iPad|Tablet/.test(ua)) device = "Tablet";
|
||||
else if (/bot|crawler|spider|slurp/i.test(ua)) device = "Bot / Crawler";
|
||||
|
||||
// Engine
|
||||
if (/Gecko\//.test(ua) && !/like Gecko/.test(ua)) engine = "Gecko";
|
||||
else if (/AppleWebKit\//.test(ua)) engine = /Chrome|Chromium|Edg/.test(ua) ? "Blink" : "WebKit";
|
||||
else if (/Trident\//.test(ua)) engine = "Trident";
|
||||
|
||||
out.innerHTML =
|
||||
row("Browser", browser + (browserVer ? " " + browserVer : "")) +
|
||||
row("OS", os + (osVer ? " " + osVer : "")) +
|
||||
row("Device Type", device) +
|
||||
row("Engine", engine);
|
||||
}
|
||||
|
||||
function useMine() {
|
||||
document.getElementById("ua-input").value = navigator.userAgent;
|
||||
parseUA();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}UUID Generator - EveryTools{% endblock %}
|
||||
{% block top_title %}UUID Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool" style="max-width:640px">
|
||||
<div class="tool-header">
|
||||
<h1>UUID Generator</h1>
|
||||
<p>Generate v4 (random) and nil UUIDs. Optionally generate many at once.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 150px;gap:.6rem;align-items:end">
|
||||
<div>
|
||||
<label>How many?</label>
|
||||
<input type="number" id="uuid-count" value="1" min="1" max="1000">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="generateUUIDs()"><i class="bi bi-arrow-repeat"></i> Generate</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:1rem">
|
||||
<label class="checkbox-label"><input type="checkbox" id="uuid-upper" onchange="generateUUIDs()"><span>Uppercase</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="uuid-braces" onchange="generateUUIDs()"><span>Wrap in braces { }</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="uuid-nodash" onchange="generateUUIDs()"><span>Remove dashes</span></label>
|
||||
</div>
|
||||
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Output</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<pre id="uuid-output" style="white-space:pre-wrap;word-break:break-all"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem;font-size:.85rem;color:var(--text-light)">
|
||||
<p><strong>Special values:</strong>
|
||||
<button class="btn btn-small" onclick="showNil()">Nil UUID</button>
|
||||
<button class="btn btn-small" onclick="showMax()">Max UUID</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function uuidv4() {
|
||||
if (crypto.randomUUID) return crypto.randomUUID();
|
||||
const b = crypto.getRandomValues(new Uint8Array(16));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = [...b].map(x => x.toString(16).padStart(2, "0")).join("");
|
||||
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function format(u) {
|
||||
if (document.getElementById("uuid-nodash").checked) u = u.replace(/-/g, "");
|
||||
if (document.getElementById("uuid-upper").checked) u = u.toUpperCase();
|
||||
if (document.getElementById("uuid-braces").checked) u = "{" + u + "}";
|
||||
return u;
|
||||
}
|
||||
|
||||
function generateUUIDs() {
|
||||
const n = Math.min(1000, Math.max(1, parseInt(document.getElementById("uuid-count").value) || 1));
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) out.push(format(uuidv4()));
|
||||
document.getElementById("uuid-output").textContent = out.join("\n");
|
||||
}
|
||||
|
||||
function showNil() {
|
||||
document.getElementById("uuid-output").textContent = format("00000000-0000-0000-0000-000000000000");
|
||||
}
|
||||
function showMax() {
|
||||
document.getElementById("uuid-output").textContent = format("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
}
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("uuid-output").textContent);
|
||||
}
|
||||
generateUUIDs();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}XML Formatter - EveryTools{% endblock %}
|
||||
{% block top_title %}XML Formatter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>XML Formatter</h1>
|
||||
<p>Format, validate, and minify XML data</p>
|
||||
</div>
|
||||
<div class="split-pane">
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Input</span>
|
||||
<div>
|
||||
<button class="btn btn-small" onclick="formatXML()">Format</button>
|
||||
<button class="btn btn-small" onclick="minifyXML()">Minify</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<textarea id="xml-input" placeholder="<root><item>value</item></root>"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-header">
|
||||
<span>Output</span>
|
||||
<button class="btn btn-small" onclick="copyOutput()"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<pre id="xml-output"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="xml-status" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function parseXML(src) {
|
||||
const doc = new DOMParser().parseFromString(src, "application/xml");
|
||||
const err = doc.querySelector("parsererror");
|
||||
if (err) throw new Error(err.textContent.split("\n")[0]);
|
||||
return doc;
|
||||
}
|
||||
|
||||
function pretty(node, indent) {
|
||||
const pad = " ".repeat(indent);
|
||||
if (node.nodeType === 3) {
|
||||
const t = node.nodeValue.trim();
|
||||
return t ? pad + t : "";
|
||||
}
|
||||
if (node.nodeType === 8) return pad + "<!--" + node.nodeValue + "-->";
|
||||
if (node.nodeType !== 1) return "";
|
||||
|
||||
const attrs = [...node.attributes].map(a => ` ${a.name}="${a.value.replace(/"/g,'"')}"`).join("");
|
||||
const name = node.nodeName;
|
||||
const kids = [...node.childNodes].filter(n => !(n.nodeType === 3 && !n.nodeValue.trim()));
|
||||
|
||||
if (!kids.length) return `${pad}<${name}${attrs}/>`;
|
||||
if (kids.length === 1 && kids[0].nodeType === 3) {
|
||||
return `${pad}<${name}${attrs}>${kids[0].nodeValue.trim()}</${name}>`;
|
||||
}
|
||||
const body = kids.map(k => pretty(k, indent + 1)).filter(Boolean).join("\n");
|
||||
return `${pad}<${name}${attrs}>\n${body}\n${pad}</${name}>`;
|
||||
}
|
||||
|
||||
function formatXML() {
|
||||
const input = document.getElementById("xml-input").value;
|
||||
const status = document.getElementById("xml-status");
|
||||
try {
|
||||
const doc = parseXML(input);
|
||||
const result = pretty(doc.documentElement, 0);
|
||||
const decl = input.trim().startsWith("<?xml") ? input.trim().match(/^<\?xml[^?]*\?>/)[0] + "\n" : "";
|
||||
document.getElementById("xml-output").textContent = decl + result;
|
||||
status.innerHTML = '<span style="color:var(--success)"><i class="bi bi-check-circle"></i> Valid XML</span>';
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function minifyXML() {
|
||||
const input = document.getElementById("xml-input").value;
|
||||
const status = document.getElementById("xml-status");
|
||||
try {
|
||||
parseXML(input);
|
||||
const out = input.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
|
||||
document.getElementById("xml-output").textContent = out;
|
||||
status.innerHTML = '<span style="color:var(--success)"><i class="bi bi-check-circle"></i> Valid XML (minified)</span>';
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function copyOutput() {
|
||||
navigator.clipboard.writeText(document.getElementById("xml-output").textContent);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -32,8 +32,8 @@
|
||||
<span>or click to browse</span>
|
||||
{% if accept %}<small>Accepted: {{ accept }}</small>{% endif %}
|
||||
</div>
|
||||
<div class="file-list" id="file-list"></div>
|
||||
</div>
|
||||
<div class="file-list" id="file-list"></div>
|
||||
{% endif %}
|
||||
|
||||
{% if options %}
|
||||
|
||||