streamlit-mini-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mini_toolkit/__init__.py +3 -0
- mini_toolkit/__main__.py +3 -0
- mini_toolkit/app.py +111 -0
- mini_toolkit/cli.py +40 -0
- mini_toolkit/core/__init__.py +1 -0
- mini_toolkit/core/net.py +56 -0
- mini_toolkit/core/pdf.py +12 -0
- mini_toolkit/core/registry.py +75 -0
- mini_toolkit/core/ui.py +90 -0
- mini_toolkit/streamlit_config.toml +16 -0
- mini_toolkit/style/style.css +171 -0
- mini_toolkit/tools/__init__.py +1 -0
- mini_toolkit/tools/background_remover.py +65 -0
- mini_toolkit/tools/change_tracker.py +90 -0
- mini_toolkit/tools/chat_alert.py +42 -0
- mini_toolkit/tools/color_picker.py +40 -0
- mini_toolkit/tools/document_converter.py +121 -0
- mini_toolkit/tools/file_compressor.py +78 -0
- mini_toolkit/tools/github_trending.py +53 -0
- mini_toolkit/tools/hash_generator.py +43 -0
- mini_toolkit/tools/image_converter.py +63 -0
- mini_toolkit/tools/image_downloader.py +67 -0
- mini_toolkit/tools/image_enhancer.py +54 -0
- mini_toolkit/tools/image_watermarker.py +63 -0
- mini_toolkit/tools/ocr.py +93 -0
- mini_toolkit/tools/page_speed.py +45 -0
- mini_toolkit/tools/password_checker.py +53 -0
- mini_toolkit/tools/pdf_tools.py +83 -0
- mini_toolkit/tools/qr_code.py +83 -0
- mini_toolkit/tools/resume_builder.py +385 -0
- mini_toolkit/tools/seo_health.py +64 -0
- mini_toolkit/tools/signature.py +72 -0
- mini_toolkit/tools/ssl_checker.py +68 -0
- mini_toolkit/tools/text_to_speech.py +34 -0
- mini_toolkit/tools/text_utilities.py +36 -0
- mini_toolkit/tools/url_shortener.py +29 -0
- mini_toolkit/tools/website_status.py +39 -0
- streamlit_mini_toolkit-0.1.0.dist-info/METADATA +151 -0
- streamlit_mini_toolkit-0.1.0.dist-info/RECORD +42 -0
- streamlit_mini_toolkit-0.1.0.dist-info/WHEEL +4 -0
- streamlit_mini_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- streamlit_mini_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Compare a page's visible text with the snapshot taken on the previous check.
|
|
2
|
+
|
|
3
|
+
Snapshots live in the system temp directory, so on hosted platforms they last
|
|
4
|
+
only as long as the server instance. Email summaries are sent when SMTP
|
|
5
|
+
credentials are configured in Streamlit secrets (see README).
|
|
6
|
+
"""
|
|
7
|
+
import difflib
|
|
8
|
+
import hashlib
|
|
9
|
+
import smtplib
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from email.mime.text import MIMEText
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import streamlit as st
|
|
16
|
+
from bs4 import BeautifulSoup
|
|
17
|
+
|
|
18
|
+
from core.net import fetch, normalize_url
|
|
19
|
+
|
|
20
|
+
SNAPSHOT_DIR = Path(tempfile.gettempdir()) / "mini_toolkit_snapshots"
|
|
21
|
+
SMTP_KEYS = ("smtp_server", "smtp_port", "smtp_user", "smtp_pass")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def page_text(url: str) -> str:
|
|
25
|
+
soup = BeautifulSoup(fetch(url, timeout=15).text, "html.parser")
|
|
26
|
+
for tag in soup(["script", "style", "noscript"]):
|
|
27
|
+
tag.decompose()
|
|
28
|
+
return "\n".join(line.strip() for line in soup.get_text("\n").splitlines() if line.strip())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def snapshot_path(url: str) -> Path:
|
|
32
|
+
return SNAPSHOT_DIR / f"{hashlib.sha256(url.encode()).hexdigest()[:24]}.txt"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def smtp_configured() -> bool:
|
|
36
|
+
try:
|
|
37
|
+
return all(key in st.secrets for key in SMTP_KEYS)
|
|
38
|
+
except Exception: # no secrets file at all
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def send_email(to: str, subject: str, body: str) -> None:
|
|
43
|
+
msg = MIMEText(body)
|
|
44
|
+
msg["Subject"], msg["From"], msg["To"] = subject, st.secrets["smtp_user"], to
|
|
45
|
+
with smtplib.SMTP(st.secrets["smtp_server"], int(st.secrets["smtp_port"]), timeout=15) as server:
|
|
46
|
+
server.starttls()
|
|
47
|
+
server.login(st.secrets["smtp_user"], st.secrets["smtp_pass"])
|
|
48
|
+
server.send_message(msg)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render() -> None:
|
|
52
|
+
url = normalize_url(st.text_input("Page URL", placeholder="https://example.com/pricing"))
|
|
53
|
+
email = st.text_input("Email a summary to (optional)", disabled=not smtp_configured(),
|
|
54
|
+
help=None if smtp_configured() else "Email is disabled: no SMTP credentials are configured on this server.")
|
|
55
|
+
if not st.button("Check for changes", type="primary", disabled=not url):
|
|
56
|
+
st.caption("The first check saves a snapshot; later checks show what changed since then.")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
with st.spinner("Fetching page…"):
|
|
60
|
+
try:
|
|
61
|
+
current = page_text(url)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
st.error(f"Could not fetch page: {exc}")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
path = snapshot_path(url)
|
|
67
|
+
previous = path.read_text(encoding="utf-8") if path.exists() else None
|
|
68
|
+
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
path.write_text(current, encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
if previous is None:
|
|
72
|
+
st.info("Snapshot saved. Run the check again later to see changes.")
|
|
73
|
+
return
|
|
74
|
+
diff = list(difflib.unified_diff(previous.splitlines(), current.splitlines(), "before", "after", lineterm="", n=1))
|
|
75
|
+
if not diff:
|
|
76
|
+
st.success("No changes since the last check.")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
added = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
|
|
80
|
+
removed = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
|
|
81
|
+
st.warning(f"Page changed: {added} line(s) added, {removed} removed.")
|
|
82
|
+
summary = "\n".join(diff[:200])
|
|
83
|
+
st.code(summary, language="diff")
|
|
84
|
+
|
|
85
|
+
if email:
|
|
86
|
+
try:
|
|
87
|
+
send_email(email, f"Change detected: {url}", f"Checked {datetime.now():%Y-%m-%d %H:%M}\n\n{summary}")
|
|
88
|
+
st.success(f"Summary emailed to {email}.")
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
st.error(f"Could not send email: {exc}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from urllib.parse import urlparse
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
import streamlit as st
|
|
5
|
+
|
|
6
|
+
# Webhooks may only point at these services, so the tool can't be used to POST to arbitrary hosts.
|
|
7
|
+
PLATFORMS = {
|
|
8
|
+
"Slack": (("hooks.slack.com",), lambda msg: {"text": msg}),
|
|
9
|
+
"Microsoft Teams": ((".webhook.office.com", ".logic.azure.com", "outlook.office.com"), lambda msg: {"text": msg}),
|
|
10
|
+
"Discord": (("discord.com", "discordapp.com"), lambda msg: {"content": msg}),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def allowed(url: str, hosts) -> bool:
|
|
15
|
+
parsed = urlparse(url)
|
|
16
|
+
host = parsed.hostname or ""
|
|
17
|
+
return parsed.scheme == "https" and any(host == h or host.endswith(h if h.startswith(".") else "." + h) for h in hosts)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send(platform: str, webhook: str, message: str) -> requests.Response:
|
|
21
|
+
_, payload = PLATFORMS[platform]
|
|
22
|
+
return requests.post(webhook, json=payload(message), timeout=10)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render() -> None:
|
|
26
|
+
platform = st.radio("Platform", list(PLATFORMS), horizontal=True)
|
|
27
|
+
webhook = st.text_input("Incoming webhook URL", type="password", help="Treated like a secret — it is never stored.").strip()
|
|
28
|
+
message = st.text_area("Message", placeholder="Deploy finished ✅")
|
|
29
|
+
|
|
30
|
+
if st.button("Send", type="primary", disabled=not (webhook and message.strip())):
|
|
31
|
+
if not allowed(webhook, PLATFORMS[platform][0]):
|
|
32
|
+
st.error(f"That doesn't look like a {platform} webhook URL.")
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
response = send(platform, webhook, message)
|
|
36
|
+
except requests.RequestException as exc:
|
|
37
|
+
st.error(f"Request failed: {exc}")
|
|
38
|
+
return
|
|
39
|
+
if response.ok:
|
|
40
|
+
st.success(f"Message sent to {platform}.")
|
|
41
|
+
else:
|
|
42
|
+
st.error(f"{platform} rejected the message ({response.status_code}): {response.text[:200]}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import streamlit as st
|
|
2
|
+
from PIL import Image
|
|
3
|
+
|
|
4
|
+
from core.ui import missing_dependency
|
|
5
|
+
|
|
6
|
+
DISPLAY_WIDTH = 700
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render() -> None:
|
|
10
|
+
try:
|
|
11
|
+
from streamlit_image_coordinates import streamlit_image_coordinates
|
|
12
|
+
except ImportError:
|
|
13
|
+
missing_dependency("streamlit-image-coordinates")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
uploaded = st.file_uploader("Upload an image", type=["png", "jpg", "jpeg", "bmp", "webp"], key="color_picker_upload")
|
|
17
|
+
if not uploaded:
|
|
18
|
+
st.info("Upload an image, then click anywhere on it to sample a colour.")
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
image = Image.open(uploaded).convert("RGB")
|
|
22
|
+
width = min(image.width, DISPLAY_WIDTH)
|
|
23
|
+
st.caption("Click on the image to pick a colour.")
|
|
24
|
+
coords = streamlit_image_coordinates(image, width=width, key="color_picker_coords")
|
|
25
|
+
if not coords:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# The image may be displayed scaled down, so map the click back to source pixels.
|
|
29
|
+
scale = image.width / width
|
|
30
|
+
x = min(int(coords["x"] * scale), image.width - 1)
|
|
31
|
+
y = min(int(coords["y"] * scale), image.height - 1)
|
|
32
|
+
r, g, b = image.getpixel((x, y))
|
|
33
|
+
hex_code = f"#{r:02X}{g:02X}{b:02X}"
|
|
34
|
+
|
|
35
|
+
swatch, values = st.columns([1, 3])
|
|
36
|
+
swatch.markdown(f"<div class='color-swatch' style='background:{hex_code}'></div>", unsafe_allow_html=True)
|
|
37
|
+
with values:
|
|
38
|
+
st.caption(f"Pixel ({x}, {y})")
|
|
39
|
+
st.code(hex_code, language=None)
|
|
40
|
+
st.code(f"rgb({r}, {g}, {b})", language=None)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import streamlit as st
|
|
8
|
+
|
|
9
|
+
from core.pdf import pdf_safe
|
|
10
|
+
from core.ui import missing_dependency, sanitize_filename
|
|
11
|
+
|
|
12
|
+
PDF_MIME = "application/pdf"
|
|
13
|
+
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
14
|
+
|
|
15
|
+
# Conversion label -> (input extension, output extension, output MIME)
|
|
16
|
+
CONVERSIONS = {
|
|
17
|
+
"DOCX → PDF": ("docx", "pdf", PDF_MIME),
|
|
18
|
+
"PDF → DOCX": ("pdf", "docx", DOCX_MIME),
|
|
19
|
+
"PDF → TXT": ("pdf", "txt", "text/plain"),
|
|
20
|
+
"DOCX → TXT": ("docx", "txt", "text/plain"),
|
|
21
|
+
"TXT → PDF": ("txt", "pdf", PDF_MIME),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def docx_to_pdf(data: bytes, filename: str) -> bytes:
|
|
26
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
27
|
+
src = Path(tmp) / filename
|
|
28
|
+
src.write_bytes(data)
|
|
29
|
+
office = shutil.which("libreoffice") or shutil.which("soffice")
|
|
30
|
+
if office:
|
|
31
|
+
subprocess.run([office, "--headless", "--convert-to", "pdf", "--outdir", tmp, str(src)],
|
|
32
|
+
check=True, timeout=120, capture_output=True)
|
|
33
|
+
return src.with_suffix(".pdf").read_bytes()
|
|
34
|
+
# Fallback for Windows/macOS machines with Microsoft Word installed.
|
|
35
|
+
from docx2pdf import convert
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import pythoncom
|
|
39
|
+
pythoncom.CoInitialize()
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
out = Path(tmp) / "output.pdf"
|
|
43
|
+
convert(str(src), str(out))
|
|
44
|
+
return out.read_bytes()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pdf_to_docx(data: bytes) -> bytes:
|
|
48
|
+
from pdf2docx import Converter
|
|
49
|
+
|
|
50
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
51
|
+
src, out = Path(tmp) / "input.pdf", Path(tmp) / "output.docx"
|
|
52
|
+
src.write_bytes(data)
|
|
53
|
+
converter = Converter(str(src))
|
|
54
|
+
try:
|
|
55
|
+
converter.convert(str(out))
|
|
56
|
+
finally:
|
|
57
|
+
converter.close()
|
|
58
|
+
return out.read_bytes()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pdf_to_txt(data: bytes) -> bytes:
|
|
62
|
+
from pypdf import PdfReader
|
|
63
|
+
|
|
64
|
+
reader = PdfReader(io.BytesIO(data))
|
|
65
|
+
return "\n\n".join(page.extract_text() or "" for page in reader.pages).encode("utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def docx_to_txt(data: bytes) -> bytes:
|
|
69
|
+
from docx import Document
|
|
70
|
+
|
|
71
|
+
return "\n".join(p.text for p in Document(io.BytesIO(data)).paragraphs).encode("utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def txt_to_pdf(data: bytes) -> bytes:
|
|
75
|
+
from fpdf import FPDF
|
|
76
|
+
|
|
77
|
+
text = data.decode("utf-8", errors="replace")
|
|
78
|
+
pdf = FPDF()
|
|
79
|
+
pdf.set_auto_page_break(auto=True, margin=15)
|
|
80
|
+
pdf.add_page()
|
|
81
|
+
pdf.set_font("Helvetica", size=11)
|
|
82
|
+
pdf.multi_cell(0, 6, pdf_safe(text))
|
|
83
|
+
return bytes(pdf.output())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def convert(label: str, data: bytes, filename: str) -> bytes:
|
|
87
|
+
if label == "DOCX → PDF":
|
|
88
|
+
return docx_to_pdf(data, filename)
|
|
89
|
+
if label == "PDF → DOCX":
|
|
90
|
+
return pdf_to_docx(data)
|
|
91
|
+
if label == "PDF → TXT":
|
|
92
|
+
return pdf_to_txt(data)
|
|
93
|
+
if label == "DOCX → TXT":
|
|
94
|
+
return docx_to_txt(data)
|
|
95
|
+
return txt_to_pdf(data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render() -> None:
|
|
99
|
+
label = st.radio("Conversion", list(CONVERSIONS), horizontal=True)
|
|
100
|
+
src_ext, out_ext, mime = CONVERSIONS[label]
|
|
101
|
+
uploaded = st.file_uploader(f"Upload a .{src_ext} file", type=[src_ext], key=f"docconv_{src_ext}")
|
|
102
|
+
if not uploaded:
|
|
103
|
+
st.info("Upload a file to convert.")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
with st.spinner("Converting…"):
|
|
107
|
+
try:
|
|
108
|
+
result = convert(label, uploaded.getvalue(), uploaded.name)
|
|
109
|
+
except ImportError as exc:
|
|
110
|
+
missing_dependency(exc.name or str(exc))
|
|
111
|
+
return
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
st.error(f"Conversion failed: {exc}")
|
|
114
|
+
if label == "DOCX → PDF":
|
|
115
|
+
st.caption("DOCX → PDF needs LibreOffice on the server, or Microsoft Word locally.")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
st.success("Conversion complete.")
|
|
119
|
+
if out_ext == "txt":
|
|
120
|
+
st.text_area("Preview", result.decode("utf-8", errors="replace")[:5000], height=240)
|
|
121
|
+
st.download_button(f"Download .{out_ext}", result, f"{sanitize_filename(uploaded.name)}.{out_ext}", mime, type="primary")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import streamlit as st
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from core.ui import build_zip, image_uploader
|
|
8
|
+
|
|
9
|
+
QUALITY = {"Smallest file": 35, "Balanced": 65, "Best quality": 85}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def shrink(f, quality: int, max_side: int) -> tuple:
|
|
13
|
+
"""Re-encode an image smaller. Returns (filename, bytes)."""
|
|
14
|
+
image = Image.open(f)
|
|
15
|
+
if max_side and max(image.size) > max_side:
|
|
16
|
+
image.thumbnail((max_side, max_side))
|
|
17
|
+
buf = io.BytesIO()
|
|
18
|
+
suffix = Path(f.name).suffix.lower()
|
|
19
|
+
if suffix == ".png" and image.mode in ("RGBA", "LA", "P"):
|
|
20
|
+
image.save(buf, format="PNG", optimize=True)
|
|
21
|
+
name = f.name
|
|
22
|
+
elif suffix == ".webp":
|
|
23
|
+
image.save(buf, format="WEBP", quality=quality)
|
|
24
|
+
name = f.name
|
|
25
|
+
else:
|
|
26
|
+
image.convert("RGB").save(buf, format="JPEG", quality=quality, optimize=True)
|
|
27
|
+
name = f"{Path(f.name).stem}.jpg"
|
|
28
|
+
return name, buf.getvalue()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _render_zip() -> None:
|
|
32
|
+
files = st.file_uploader("Upload any files", accept_multiple_files=True, key="zip_upload")
|
|
33
|
+
if not files:
|
|
34
|
+
st.info("Upload files to bundle into a ZIP archive.")
|
|
35
|
+
return
|
|
36
|
+
data = build_zip((f.name, f.getvalue()) for f in files)
|
|
37
|
+
original = sum(f.size for f in files)
|
|
38
|
+
st.metric("Archive size", f"{len(data) / 1024:.1f} KB", f"{(len(data) - original) / max(original, 1):+.0%}", delta_color="inverse")
|
|
39
|
+
st.download_button("Download ZIP", data, "archive.zip", "application/zip", type="primary")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _render_images() -> None:
|
|
43
|
+
left, right = st.columns(2)
|
|
44
|
+
quality = QUALITY[left.radio("Quality", list(QUALITY), index=1, horizontal=True)]
|
|
45
|
+
max_side = right.selectbox("Max dimension", [0, 3840, 2560, 1920, 1280, 800], format_func=lambda v: "Keep original" if v == 0 else f"{v}px")
|
|
46
|
+
files = image_uploader("shrink_upload", types=("png", "jpg", "jpeg", "webp"))
|
|
47
|
+
if not files:
|
|
48
|
+
st.info("Upload images to reduce their file size.")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
outputs, before, after = [], 0, 0
|
|
52
|
+
for f in files:
|
|
53
|
+
try:
|
|
54
|
+
name, data = shrink(f, quality, max_side)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
st.warning(f"Skipped {f.name}: {exc}")
|
|
57
|
+
continue
|
|
58
|
+
outputs.append((name, data))
|
|
59
|
+
before += f.size
|
|
60
|
+
after += len(data)
|
|
61
|
+
|
|
62
|
+
if outputs:
|
|
63
|
+
cols = st.columns(3)
|
|
64
|
+
cols[0].metric("Before", f"{before / 1024:.0f} KB")
|
|
65
|
+
cols[1].metric("After", f"{after / 1024:.0f} KB")
|
|
66
|
+
cols[2].metric("Saved", f"{1 - after / max(before, 1):.0%}")
|
|
67
|
+
if len(outputs) == 1:
|
|
68
|
+
st.download_button("Download image", outputs[0][1], outputs[0][0], type="primary")
|
|
69
|
+
else:
|
|
70
|
+
st.download_button("Download all (ZIP)", build_zip(outputs), "compressed_images.zip", "application/zip", type="primary")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render() -> None:
|
|
74
|
+
images_tab, zip_tab = st.tabs(["Shrink images", "Create ZIP"])
|
|
75
|
+
with images_tab:
|
|
76
|
+
_render_images()
|
|
77
|
+
with zip_tab:
|
|
78
|
+
_render_zip()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
import streamlit as st
|
|
5
|
+
|
|
6
|
+
PERIODS = {"Today": 1, "This week": 7, "This month": 30}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@st.cache_data(ttl=900, show_spinner=False)
|
|
10
|
+
def trending(days: int, language: str, limit: int = 20) -> list:
|
|
11
|
+
"""Approximates GitHub Trending: most-starred repositories created in the window."""
|
|
12
|
+
query = f"created:>{date.today() - timedelta(days=days)}"
|
|
13
|
+
if language:
|
|
14
|
+
query += f" language:{language}"
|
|
15
|
+
response = requests.get(
|
|
16
|
+
"https://api.github.com/search/repositories",
|
|
17
|
+
params={"q": query, "sort": "stars", "order": "desc", "per_page": limit},
|
|
18
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
19
|
+
timeout=15,
|
|
20
|
+
)
|
|
21
|
+
response.raise_for_status()
|
|
22
|
+
return response.json().get("items", [])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render() -> None:
|
|
26
|
+
left, right = st.columns(2)
|
|
27
|
+
period = left.selectbox("Created", list(PERIODS), index=1)
|
|
28
|
+
language = right.text_input("Language", placeholder="Any — e.g. python, rust").strip().lower()
|
|
29
|
+
|
|
30
|
+
with st.spinner("Fetching from GitHub…"):
|
|
31
|
+
try:
|
|
32
|
+
repos = trending(PERIODS[period], language)
|
|
33
|
+
except requests.HTTPError as exc:
|
|
34
|
+
if exc.response is not None and exc.response.status_code == 403:
|
|
35
|
+
st.error("GitHub's rate limit was reached. Try again in a minute.")
|
|
36
|
+
else:
|
|
37
|
+
st.error(f"GitHub request failed: {exc}")
|
|
38
|
+
return
|
|
39
|
+
except requests.RequestException as exc:
|
|
40
|
+
st.error(f"Could not reach GitHub: {exc}")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if not repos:
|
|
44
|
+
st.info("No repositories found. Try a longer period or another language.")
|
|
45
|
+
return
|
|
46
|
+
for repo in repos:
|
|
47
|
+
with st.container(border=True):
|
|
48
|
+
title, stars = st.columns([5, 1])
|
|
49
|
+
title.markdown(f"**[{repo['full_name']}]({repo['html_url']})**")
|
|
50
|
+
stars.markdown(f"⭐ {repo['stargazers_count']:,}")
|
|
51
|
+
if repo.get("description"):
|
|
52
|
+
st.write(repo["description"])
|
|
53
|
+
st.caption(" · ".join(filter(None, [repo.get("language"), f"{repo['forks_count']:,} forks"])))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
ALGORITHMS = {
|
|
6
|
+
"MD5": hashlib.md5,
|
|
7
|
+
"SHA-1": hashlib.sha1,
|
|
8
|
+
"SHA-256": hashlib.sha256,
|
|
9
|
+
"SHA-512": hashlib.sha512,
|
|
10
|
+
"SHA3-256": hashlib.sha3_256,
|
|
11
|
+
"BLAKE2b": hashlib.blake2b,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def digest(data: bytes, algorithm: str) -> str:
|
|
16
|
+
return ALGORITHMS[algorithm](data).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render() -> None:
|
|
20
|
+
source = st.radio("Input", ["Text", "File"], horizontal=True)
|
|
21
|
+
if source == "Text":
|
|
22
|
+
text = st.text_area("Text to hash", height=120)
|
|
23
|
+
data = text.encode("utf-8") if text else None
|
|
24
|
+
else:
|
|
25
|
+
uploaded = st.file_uploader("File to hash", key="hash_file")
|
|
26
|
+
data = uploaded.getvalue() if uploaded else None
|
|
27
|
+
|
|
28
|
+
selected = st.multiselect("Algorithms", list(ALGORITHMS), default=["MD5", "SHA-256"])
|
|
29
|
+
if data is None:
|
|
30
|
+
st.info("Enter text or upload a file to compute its hash.")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
for algorithm in selected:
|
|
34
|
+
st.caption(algorithm)
|
|
35
|
+
st.code(digest(data, algorithm), language=None)
|
|
36
|
+
|
|
37
|
+
expected = st.text_input("Verify against a known hash (optional)").strip().lower()
|
|
38
|
+
if expected:
|
|
39
|
+
match = next((a for a in selected if digest(data, a) == expected), None)
|
|
40
|
+
if match:
|
|
41
|
+
st.success(f"Match — the {match} hash is identical.")
|
|
42
|
+
else:
|
|
43
|
+
st.error("No match among the selected algorithms.")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
from core.ui import build_zip, image_uploader, sanitize_filename, section
|
|
7
|
+
|
|
8
|
+
# Display label -> (Pillow format, extension, MIME type, supports alpha)
|
|
9
|
+
FORMATS = {
|
|
10
|
+
"PNG": ("PNG", "png", "image/png", True),
|
|
11
|
+
"JPG": ("JPEG", "jpg", "image/jpeg", False),
|
|
12
|
+
"WEBP": ("WEBP", "webp", "image/webp", True),
|
|
13
|
+
"BMP": ("BMP", "bmp", "image/bmp", False),
|
|
14
|
+
"TIFF": ("TIFF", "tiff", "image/tiff", True),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def convert(image: Image.Image, label: str) -> bytes:
|
|
19
|
+
fmt, _, _, supports_alpha = FORMATS[label]
|
|
20
|
+
if not supports_alpha and image.mode in ("RGBA", "LA", "P"):
|
|
21
|
+
# Flatten transparency onto white instead of letting it turn black.
|
|
22
|
+
rgba = image.convert("RGBA")
|
|
23
|
+
background = Image.new("RGB", rgba.size, (255, 255, 255))
|
|
24
|
+
background.paste(rgba, mask=rgba.split()[-1])
|
|
25
|
+
image = background
|
|
26
|
+
elif not supports_alpha and image.mode != "RGB":
|
|
27
|
+
image = image.convert("RGB")
|
|
28
|
+
buf = io.BytesIO()
|
|
29
|
+
image.save(buf, format=fmt)
|
|
30
|
+
return buf.getvalue()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render() -> None:
|
|
34
|
+
target = st.radio("Convert to", list(FORMATS), horizontal=True)
|
|
35
|
+
files = image_uploader("converter_upload")
|
|
36
|
+
if not files:
|
|
37
|
+
st.info("Upload one or more images to convert.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
_, ext, mime, _ = FORMATS[target]
|
|
41
|
+
section("Converted images")
|
|
42
|
+
outputs = []
|
|
43
|
+
for i, f in enumerate(files):
|
|
44
|
+
try:
|
|
45
|
+
original = Image.open(f)
|
|
46
|
+
data = convert(original, target)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
st.error(f"Could not convert {f.name}: {exc}")
|
|
49
|
+
continue
|
|
50
|
+
name = f"{sanitize_filename(f.name)}.{ext}"
|
|
51
|
+
outputs.append((name, data))
|
|
52
|
+
with st.container(border=True):
|
|
53
|
+
cols = st.columns([1, 2])
|
|
54
|
+
cols[0].image(original, width="stretch")
|
|
55
|
+
cols[1].markdown(f"**{f.name}** → `{name}`")
|
|
56
|
+
cols[1].caption(
|
|
57
|
+
f"{original.format} · {original.width}×{original.height} · "
|
|
58
|
+
f"{f.size / 1024:.1f} KB → {len(data) / 1024:.1f} KB"
|
|
59
|
+
)
|
|
60
|
+
cols[1].download_button(f"Download {target}", data, name, mime, key=f"conv_dl_{i}")
|
|
61
|
+
|
|
62
|
+
if len(outputs) > 1:
|
|
63
|
+
st.download_button("Download all as ZIP", build_zip(outputs), "converted_images.zip", "application/zip", type="primary")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
from pathlib import PurePosixPath
|
|
3
|
+
from urllib.parse import urljoin, urlparse
|
|
4
|
+
|
|
5
|
+
import streamlit as st
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from core.net import fetch
|
|
9
|
+
from core.ui import build_zip
|
|
10
|
+
|
|
11
|
+
MAX_IMAGES = 60
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_images(url: str) -> list:
|
|
15
|
+
response = fetch(url, timeout=15)
|
|
16
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
17
|
+
found = []
|
|
18
|
+
for img in soup.find_all("img"):
|
|
19
|
+
src = img.get("src") or img.get("data-src")
|
|
20
|
+
if src and not src.startswith("data:"):
|
|
21
|
+
found.append(urljoin(response.url, src))
|
|
22
|
+
return list(dict.fromkeys(found))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def download(url: str):
|
|
26
|
+
try:
|
|
27
|
+
response = fetch(url, timeout=15)
|
|
28
|
+
if response.ok and response.headers.get("content-type", "").startswith("image/"):
|
|
29
|
+
return url, response.content
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return url, None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render() -> None:
|
|
36
|
+
url = st.text_input("Page URL", placeholder="https://example.com")
|
|
37
|
+
if st.button("Find images", type="primary", disabled=not url.strip()):
|
|
38
|
+
with st.spinner("Scanning page…"):
|
|
39
|
+
try:
|
|
40
|
+
st.session_state.img_dl_urls = find_images(url)[:MAX_IMAGES]
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
st.error(f"Could not load page: {exc}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
urls = st.session_state.get("img_dl_urls")
|
|
46
|
+
if urls is None:
|
|
47
|
+
return
|
|
48
|
+
if not urls:
|
|
49
|
+
st.warning("No images found on that page.")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
st.caption(f"Found {len(urls)} image{'s' if len(urls) != 1 else ''} (showing up to {MAX_IMAGES}).")
|
|
53
|
+
cols = st.columns(4)
|
|
54
|
+
for i, img_url in enumerate(urls):
|
|
55
|
+
cols[i % 4].image(img_url, width="stretch")
|
|
56
|
+
|
|
57
|
+
if st.button("Prepare ZIP download"):
|
|
58
|
+
with st.spinner("Downloading images…"), ThreadPoolExecutor(max_workers=8) as pool:
|
|
59
|
+
results = [(u, data) for u, data in pool.map(download, urls) if data]
|
|
60
|
+
entries, seen = [], set()
|
|
61
|
+
for i, (img_url, data) in enumerate(results):
|
|
62
|
+
name = PurePosixPath(urlparse(img_url).path).name or f"image_{i}"
|
|
63
|
+
if name in seen:
|
|
64
|
+
name = f"{i}_{name}"
|
|
65
|
+
seen.add(name)
|
|
66
|
+
entries.append((name, data))
|
|
67
|
+
st.download_button(f"Download {len(entries)} images (ZIP)", build_zip(entries), "images.zip", "application/zip", type="primary")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
from PIL import Image, ImageEnhance
|
|
5
|
+
|
|
6
|
+
from core.ui import build_zip, image_uploader, sanitize_filename, section
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def enhance(image: Image.Image, brightness: float, contrast: float, color: float, sharpness: float) -> Image.Image:
|
|
10
|
+
if image.mode not in ("RGB", "RGBA"):
|
|
11
|
+
image = image.convert("RGBA")
|
|
12
|
+
for enhancer, factor in (
|
|
13
|
+
(ImageEnhance.Brightness, brightness),
|
|
14
|
+
(ImageEnhance.Contrast, contrast),
|
|
15
|
+
(ImageEnhance.Color, color),
|
|
16
|
+
(ImageEnhance.Sharpness, sharpness),
|
|
17
|
+
):
|
|
18
|
+
image = enhancer(image).enhance(factor)
|
|
19
|
+
return image
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render() -> None:
|
|
23
|
+
cols = st.columns(4)
|
|
24
|
+
brightness = cols[0].slider("Brightness", 0.5, 2.0, 1.0, 0.05)
|
|
25
|
+
contrast = cols[1].slider("Contrast", 0.5, 2.0, 1.2, 0.05)
|
|
26
|
+
color = cols[2].slider("Colour", 0.5, 2.0, 1.0, 0.05)
|
|
27
|
+
sharpness = cols[3].slider("Sharpness", 0.5, 2.0, 1.2, 0.05)
|
|
28
|
+
|
|
29
|
+
files = image_uploader("enhancer_upload")
|
|
30
|
+
if not files:
|
|
31
|
+
st.info("Upload one or more images to enhance.")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
section("Enhanced images")
|
|
35
|
+
outputs = []
|
|
36
|
+
for i, f in enumerate(files):
|
|
37
|
+
try:
|
|
38
|
+
original = Image.open(f)
|
|
39
|
+
result = enhance(original, brightness, contrast, color, sharpness)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
st.error(f"Could not enhance {f.name}: {exc}")
|
|
42
|
+
continue
|
|
43
|
+
buf = io.BytesIO()
|
|
44
|
+
result.save(buf, format="PNG")
|
|
45
|
+
name = f"{sanitize_filename(f.name)}_enhanced.png"
|
|
46
|
+
outputs.append((name, buf.getvalue()))
|
|
47
|
+
with st.container(border=True):
|
|
48
|
+
left, right = st.columns(2)
|
|
49
|
+
left.image(original, caption="Original", width="stretch")
|
|
50
|
+
right.image(result, caption="Enhanced", width="stretch")
|
|
51
|
+
st.download_button("Download PNG", buf.getvalue(), name, "image/png", key=f"enh_dl_{i}")
|
|
52
|
+
|
|
53
|
+
if len(outputs) > 1:
|
|
54
|
+
st.download_button("Download all as ZIP", build_zip(outputs), "enhanced_images.zip", "application/zip", type="primary")
|