change structure
This commit is contained in:
parent
2d61260583
commit
4f4fe1eff2
11 changed files with 126 additions and 726 deletions
|
|
@ -1,368 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Dictations Viewer</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #0f1117;
|
|
||||||
--surface: #1a1d27;
|
|
||||||
--surface-hover: #242836;
|
|
||||||
--border: rgba(255,255,255,0.08);
|
|
||||||
--text: #e4e6ed;
|
|
||||||
--text-muted: #8b8fa3;
|
|
||||||
--accent: #6c5ce7;
|
|
||||||
--accent-glow: rgba(108,92,231,0.25);
|
|
||||||
--accent-light: #a29bfe;
|
|
||||||
--success: #00cec9;
|
|
||||||
--danger: #ff7675;
|
|
||||||
--radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
min-height: 100dvh;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px 16px 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
background: linear-gradient(135deg, var(--accent-light), var(--success));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header .subtitle {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-item {
|
|
||||||
display: block;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s, transform 0.15s;
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeSlideIn 0.3s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-item:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border-color: rgba(108,92,231,0.3);
|
|
||||||
transform: translateX(3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-item:active {
|
|
||||||
transform: translateX(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-item.selected {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--surface-hover);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-summary {
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-arrow {
|
|
||||||
float: right;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dictation-item:hover .dictation-arrow { opacity: 1; }
|
|
||||||
|
|
||||||
.detail-panel {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: none;
|
|
||||||
animation: fadeIn 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel.visible { display: block; }
|
|
||||||
|
|
||||||
.detail-title {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, color 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-btn.copied {
|
|
||||||
background: var(--success);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-tag {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: rgba(108,92,231,0.15);
|
|
||||||
color: var(--accent-light);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-date {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-content {
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeSlideIn {
|
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: 3px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-message {
|
|
||||||
text-align: center;
|
|
||||||
padding: 48px 16px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text { color: var(--danger); }
|
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
|
||||||
.container { padding: 16px 12px 40px; }
|
|
||||||
.header h1 { font-size: 1.25rem; }
|
|
||||||
.dictation-item { padding: 12px 14px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<header class="header">
|
|
||||||
<h1>Dictations</h1>
|
|
||||||
<p class="subtitle">Recent voice notes</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="list" class="dictation-list"></div>
|
|
||||||
<div id="detail" class="detail-panel"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_URL = {{ url_for('list_dictations') | tojson }};
|
|
||||||
const API_KEY = {{ api_key | tojson }};
|
|
||||||
|
|
||||||
let dictations = [];
|
|
||||||
let selectedIndex = -1;
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (str == null) return '';
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str;
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(created_at) {
|
|
||||||
if (!created_at) return '';
|
|
||||||
const parts = created_at.split('-');
|
|
||||||
if (parts.length !== 2) return created_at;
|
|
||||||
const day = parts[0];
|
|
||||||
const time = parts[1];
|
|
||||||
if (day.length < 6 || time.length < 4) return created_at;
|
|
||||||
const d = day.slice(0, 2);
|
|
||||||
const m = day.slice(2, 4);
|
|
||||||
const y = day.slice(4);
|
|
||||||
const h = time.slice(0, 2);
|
|
||||||
const min = time.slice(2);
|
|
||||||
return `${m}/${d}/${y} ${h}:${min}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderList() {
|
|
||||||
const listEl = document.getElementById('list');
|
|
||||||
|
|
||||||
if (dictations.length === 0) {
|
|
||||||
listEl.innerHTML = '<div class="state-message">No dictations found.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = dictations.map((d, i) => {
|
|
||||||
const selected = i === selectedIndex ? ' selected' : '';
|
|
||||||
return `
|
|
||||||
<div class="dictation-item${selected}" style="animation-delay: ${i * 40}ms" onclick="selectItem(${i})">
|
|
||||||
<span class="dictation-arrow">\u203A</span>
|
|
||||||
<div class="dictation-summary">${escapeHtml(d.summary || d.title || 'Untitled')}</div>
|
|
||||||
<div class="dictation-meta">
|
|
||||||
<span>${escapeHtml(d.title || '')}</span>
|
|
||||||
${d.created_at ? `<span>${formatDate(d.created_at)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectItem(index) {
|
|
||||||
selectedIndex = index;
|
|
||||||
renderList();
|
|
||||||
|
|
||||||
const d = dictations[index];
|
|
||||||
const detailEl = document.getElementById('detail');
|
|
||||||
|
|
||||||
const tags = (d.tags || []).map(t => `<span class="detail-tag">${escapeHtml(t)}</span>`).join('');
|
|
||||||
|
|
||||||
detailEl.innerHTML = `
|
|
||||||
<div class="detail-header">
|
|
||||||
<div class="detail-title">${escapeHtml(d.title || 'Untitled')}</div>
|
|
||||||
<button class="copy-btn" onclick="copyContent(${index}, this)">Copy</button>
|
|
||||||
</div>
|
|
||||||
<div class="detail-meta">
|
|
||||||
${d.created_at ? `<span class="detail-date">${formatDate(d.created_at)}</span>` : ''}
|
|
||||||
${tags}
|
|
||||||
</div>
|
|
||||||
<div class="detail-content">${escapeHtml(d.content || '')}</div>`;
|
|
||||||
|
|
||||||
detailEl.classList.add('visible');
|
|
||||||
detailEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyContent(index, btn) {
|
|
||||||
const d = dictations[index];
|
|
||||||
navigator.clipboard.writeText(d.content || '').then(() => {
|
|
||||||
btn.textContent = 'Copied';
|
|
||||||
btn.classList.add('copied');
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.textContent = 'Copy';
|
|
||||||
btn.classList.remove('copied');
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const listEl = document.getElementById('list');
|
|
||||||
listEl.innerHTML = '<div class="state-message"><div class="spinner"></div>Loading dictations...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(API_URL, { headers: { 'x-api-key': API_KEY } });
|
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
||||||
dictations = await res.json();
|
|
||||||
selectedIndex = -1;
|
|
||||||
document.getElementById('detail').classList.remove('visible');
|
|
||||||
renderList();
|
|
||||||
} catch (err) {
|
|
||||||
listEl.innerHTML = `
|
|
||||||
<div class="state-message">
|
|
||||||
<span class="error-text">Failed to load dictations</span><br>
|
|
||||||
<small style="color:var(--text-muted)">${escapeHtml(err.message)}</small>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
110
print/app.py
110
print/app.py
|
|
@ -1,110 +0,0 @@
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pypdf
|
|
||||||
from flask import Flask, flash, redirect, render_template, request, url_for
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
UPLOAD_FOLDER = str(Path(__file__).parent / "uploads")
|
|
||||||
ALLOWED_EXTENSIONS = {"pdf"}
|
|
||||||
PRINTER_NAME = "HL-2270DW_series"
|
|
||||||
MAX_PAGES = 10
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
|
||||||
app.config["SECRET_KEY"] = os.urandom(24)
|
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
|
||||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
||||||
|
|
||||||
|
|
||||||
def get_pdf_page_count(filepath):
|
|
||||||
try:
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
reader = pypdf.PdfReader(f)
|
|
||||||
return len(reader.pages)
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Could not read PDF pages: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
|
||||||
def upload_and_print():
|
|
||||||
if request.method == "POST":
|
|
||||||
if "file" not in request.files:
|
|
||||||
flash("No file part in the request.", "error")
|
|
||||||
return redirect(request.url)
|
|
||||||
|
|
||||||
file = request.files["file"]
|
|
||||||
|
|
||||||
if file.filename == "":
|
|
||||||
flash("No file selected. Please choose a PDF to print.", "error")
|
|
||||||
return redirect(request.url)
|
|
||||||
|
|
||||||
if file and allowed_file(file.filename):
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
|
|
||||||
filepath = os.path.join(app.config["UPLOAD_FOLDER"], filename)
|
|
||||||
file.save(filepath)
|
|
||||||
|
|
||||||
try:
|
|
||||||
page_count = get_pdf_page_count(filepath)
|
|
||||||
if page_count is None:
|
|
||||||
flash(
|
|
||||||
"Could not process the PDF file. It might be corrupt.", "error"
|
|
||||||
)
|
|
||||||
return redirect(request.url)
|
|
||||||
|
|
||||||
if page_count > MAX_PAGES:
|
|
||||||
flash(
|
|
||||||
f"Error: File has {page_count} pages. The maximum allowed is {MAX_PAGES}.",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
return redirect(request.url)
|
|
||||||
|
|
||||||
app.logger.info(
|
|
||||||
f"Sending '{filename}' ({page_count} pages) to printer '{PRINTER_NAME}'"
|
|
||||||
)
|
|
||||||
command = [
|
|
||||||
"lp",
|
|
||||||
"-d",
|
|
||||||
PRINTER_NAME,
|
|
||||||
filepath,
|
|
||||||
]
|
|
||||||
result = subprocess.run(
|
|
||||||
command, capture_output=True, text=True, check=True
|
|
||||||
)
|
|
||||||
app.logger.info(f"lp command stdout: {result.stdout}")
|
|
||||||
flash(f"Success! '{filename}' has been sent to the printer.", "success")
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
app.logger.error(f"Error printing file: {e}")
|
|
||||||
app.logger.error(f"lp command stderr: {e.stderr}")
|
|
||||||
flash(
|
|
||||||
"Error sending file to printer. The printer may be offline or misconfigured.",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"An unexpected error occurred: {e}")
|
|
||||||
flash("An unexpected server error occurred.", "error")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
app.logger.info(f"Cleaned up temporary file: {filepath}")
|
|
||||||
|
|
||||||
return redirect(url_for("upload_and_print"))
|
|
||||||
|
|
||||||
else:
|
|
||||||
flash("Invalid file type. Please upload a PDF file.", "error")
|
|
||||||
return redirect(request.url)
|
|
||||||
|
|
||||||
return render_template("index.html", printer_name=PRINTER_NAME, max_pages=MAX_PAGES)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(host="127.0.0.1", port=57928, debug=False)
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Web Print Service</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100 text-gray-800 flex items-center justify-center min-h-screen">
|
|
||||||
|
|
||||||
<div class="w-full max-w-lg mx-auto p-4 md:p-8">
|
|
||||||
<div class="bg-white rounded-2xl shadow-lg p-8">
|
|
||||||
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 mx-auto text-blue-500 mb-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">PDF Web Print</h1>
|
|
||||||
<p class="text-gray-500 mt-2">Upload a PDF to print to <strong>{{ printer_name }}</strong>.</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">(Max {{ max_pages }} pages)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="mb-6 space-y-3">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="p-4 rounded-lg text-sm {% if category == 'error' %} bg-red-100 text-red-800 {% else %} bg-green-100 text-green-800 {% endif %}" role="alert">
|
|
||||||
<span class="font-medium">{% if category == 'error' %}Oops!{% else %}Success!{% endif %}</span> {{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form action="{{ url_for('upload_and_print') }}" method="post" enctype="multipart/form-data">
|
|
||||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center bg-gray-50 hover:border-blue-500 transition-colors duration-300">
|
|
||||||
<label for="file-upload" class="cursor-pointer">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
||||||
</svg>
|
|
||||||
<p id="file-name" class="mt-4 text-gray-600">
|
|
||||||
<span class="font-semibold text-blue-600">Click to upload</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">PDF only, max 16MB.</p>
|
|
||||||
</label>
|
|
||||||
<input id="file-upload" name="file" type="file" class="sr-only" accept=".pdf">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<button type="submit" class="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 transition-all duration-300 transform hover:scale-105">
|
|
||||||
Print File
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="text-center mt-6 text-sm text-gray-500">
|
|
||||||
<p>A simple Flask web app for local printing.</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const fileUpload = document.getElementById('file-upload');
|
|
||||||
const fileNameDisplay = document.getElementById('file-name');
|
|
||||||
|
|
||||||
fileUpload.addEventListener('change', () => {
|
|
||||||
if (fileUpload.files.length > 0) {
|
|
||||||
fileNameDisplay.innerHTML = `<span class="font-semibold text-gray-800">${fileUpload.files[0].name}</span>`;
|
|
||||||
} else {
|
|
||||||
fileNameDisplay.innerHTML = `<span class="font-semibold text-blue-600">Click to upload</span> or drag and drop.`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import io
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import pypdf
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app import app as flask_app, get_pdf_page_count
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app():
|
|
||||||
flask_app.config["TESTING"] = True
|
|
||||||
flask_app.config["UPLOAD_FOLDER"] = tempfile.mkdtemp()
|
|
||||||
flask_app.config["SECRET_KEY"] = "test-secret"
|
|
||||||
yield flask_app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(app):
|
|
||||||
return app.test_client()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_pdf(num_pages=3):
|
|
||||||
writer = pypdf.PdfWriter()
|
|
||||||
for _ in range(num_pages):
|
|
||||||
writer.add_blank_page(width=612, height=792)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
writer.write(buf)
|
|
||||||
buf.seek(0)
|
|
||||||
return buf
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_pdf():
|
|
||||||
return _make_pdf(3).read()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def large_pdf():
|
|
||||||
return _make_pdf(15).read()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def corrupt_file():
|
|
||||||
return b"This is not a PDF at all"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_pdf_path(tmp_path):
|
|
||||||
path = tmp_path / "test.pdf"
|
|
||||||
path.write_bytes(_make_pdf(3).read())
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def corrupt_file_path(tmp_path):
|
|
||||||
path = tmp_path / "corrupt.pdf"
|
|
||||||
path.write_bytes(b"not a pdf")
|
|
||||||
return str(path)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
from app import get_pdf_page_count
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_count_valid_pdf(sample_pdf_path):
|
|
||||||
assert get_pdf_page_count(sample_pdf_path) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_count_corrupt_file(corrupt_file_path):
|
|
||||||
assert get_pdf_page_count(corrupt_file_path) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_count_nonexistent_file():
|
|
||||||
assert get_pdf_page_count("/nonexistent/file.pdf") is None
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import io
|
|
||||||
import os
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_index(client):
|
|
||||||
resp = client.get("/")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"printer" in resp.data.lower() or b"upload" in resp.data.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_no_file(client):
|
|
||||||
resp = client.post("/", data={}, follow_redirects=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"No file part" in resp.data
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_empty_filename(client):
|
|
||||||
resp = client.post("/", data={"file": (io.BytesIO(b""), "")}, follow_redirects=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"No file selected" in resp.data
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_invalid_extension(client):
|
|
||||||
data = {"file": (io.BytesIO(b"hello"), "document.txt")}
|
|
||||||
resp = client.post("/", data=data, follow_redirects=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"Invalid file type" in resp.data
|
|
||||||
|
|
||||||
|
|
||||||
@patch("app.subprocess.run")
|
|
||||||
def test_post_valid_pdf(mock_run, client, app, sample_pdf):
|
|
||||||
mock_run.return_value.stdout = "request-id-123"
|
|
||||||
mock_run.return_value.stderr = ""
|
|
||||||
mock_run.return_value.returncode = 0
|
|
||||||
|
|
||||||
data = {"file": (io.BytesIO(sample_pdf), "test.pdf")}
|
|
||||||
resp = client.post("/", data=data, follow_redirects=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"Success" in resp.data
|
|
||||||
|
|
||||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
|
||||||
remaining = os.listdir(upload_dir)
|
|
||||||
assert len(remaining) == 0
|
|
||||||
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert cmd[0] == "lp"
|
|
||||||
|
|
||||||
|
|
||||||
@patch("app.subprocess.run")
|
|
||||||
def test_post_pdf_exceeds_max_pages(mock_run, client, large_pdf):
|
|
||||||
data = {"file": (io.BytesIO(large_pdf), "big.pdf")}
|
|
||||||
resp = client.post("/", data=data, follow_redirects=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"maximum allowed" in resp.data.lower()
|
|
||||||
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_corrupt_pdf(client, app, corrupt_file):
|
|
||||||
data = {"file": (io.BytesIO(corrupt_file), "bad.pdf")}
|
|
||||||
resp = client.post("/", data=data, follow_redirects=True)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b"corrupt" in resp.data.lower()
|
|
||||||
|
|
||||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
|
||||||
remaining = os.listdir(upload_dir)
|
|
||||||
assert len(remaining) == 0
|
|
||||||
|
|
@ -8,3 +8,8 @@ dependencies = [
|
||||||
"pymongo>=4.0",
|
"pymongo>=4.0",
|
||||||
"pypdf>=4.0",
|
"pypdf>=4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.0.3",
|
||||||
|
]
|
||||||
|
|
|
||||||
81
server.py
81
server.py
|
|
@ -6,24 +6,52 @@ from flask import Flask, render_template_string
|
||||||
from werkzeug.middleware.dispatcher import DispatcherMiddleware
|
from werkzeug.middleware.dispatcher import DispatcherMiddleware
|
||||||
from werkzeug.serving import run_simple
|
from werkzeug.serving import run_simple
|
||||||
|
|
||||||
|
CATEGORIES = ["personal", "home", "work"]
|
||||||
|
|
||||||
root = Flask(__name__)
|
root = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_apps():
|
||||||
|
apps = []
|
||||||
|
base = Path(__file__).parent
|
||||||
|
for category in CATEGORIES:
|
||||||
|
cat_dir = base / category
|
||||||
|
if not cat_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for app_dir in sorted(cat_dir.iterdir()):
|
||||||
|
if app_dir.is_dir() and (app_dir / "app.py").exists():
|
||||||
|
apps.append((category, app_dir.name))
|
||||||
|
return apps
|
||||||
|
|
||||||
|
|
||||||
@root.route("/")
|
@root.route("/")
|
||||||
def index():
|
def index():
|
||||||
apps = [
|
apps = _discover_apps()
|
||||||
d.name
|
grouped: dict[str, list[tuple[str, str]]] = {}
|
||||||
for d in Path(__file__).parent.iterdir()
|
for category, app_name in apps:
|
||||||
if d.is_dir() and (d / "app.py").exists()
|
grouped.setdefault(category, []).append((category, app_name))
|
||||||
]
|
|
||||||
items = "".join(
|
sections = ""
|
||||||
f'<a href="/{a}" class="app-item" style="animation-delay:{i * 50}ms">'
|
delay = 0
|
||||||
|
for category in CATEGORIES:
|
||||||
|
if category not in grouped:
|
||||||
|
continue
|
||||||
|
items_html = ""
|
||||||
|
for cat, name in grouped[category]:
|
||||||
|
items_html += (
|
||||||
|
f'<a href="/{cat}/{name}" class="app-item" style="animation-delay:{delay * 50}ms">'
|
||||||
f'<span class="app-arrow">\u203a</span>'
|
f'<span class="app-arrow">\u203a</span>'
|
||||||
f'<div class="app-name">{a.replace("_", " ").replace("-", " ").title()}</div>'
|
f'<div class="app-name">{name.replace("_", " ").replace("-", " ").title()}</div>'
|
||||||
f'<div class="app-path">/{a}</div>'
|
f'<div class="app-path">/{cat}/{name}</div>'
|
||||||
f"</a>"
|
f"</a>"
|
||||||
for i, a in enumerate(sorted(apps))
|
|
||||||
)
|
)
|
||||||
|
delay += 1
|
||||||
|
sections += f"""
|
||||||
|
<div class="category">
|
||||||
|
<h2 class="category-title">{category.title()}</h2>
|
||||||
|
<div class="app-list">{items_html}</div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
return render_template_string(f"""
|
return render_template_string(f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -86,6 +114,20 @@ def index():
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
.category {{
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.category-title {{
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}}
|
||||||
|
|
||||||
.app-list {{
|
.app-list {{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -154,7 +196,7 @@ def index():
|
||||||
<h1>Tools</h1>
|
<h1>Tools</h1>
|
||||||
<p class="subtitle">Flask applications</p>
|
<p class="subtitle">Flask applications</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="app-list">{items}</div>
|
{sections}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -164,18 +206,21 @@ def index():
|
||||||
def load_sub_apps():
|
def load_sub_apps():
|
||||||
mounts = {}
|
mounts = {}
|
||||||
base = Path(__file__).parent
|
base = Path(__file__).parent
|
||||||
for app_dir in sorted(base.iterdir()):
|
for category in CATEGORIES:
|
||||||
|
cat_dir = base / category
|
||||||
|
if not cat_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for app_dir in sorted(cat_dir.iterdir()):
|
||||||
app_file = app_dir / "app.py"
|
app_file = app_dir / "app.py"
|
||||||
if not app_dir.is_dir() or not app_file.exists():
|
if not app_dir.is_dir() or not app_file.exists():
|
||||||
continue
|
continue
|
||||||
spec = importlib.util.spec_from_file_location(app_dir.name, app_file)
|
module_name = f"{category}.{app_dir.name}"
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, app_file)
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
sys.modules[app_dir.name] = (
|
sys.modules[module_name] = mod
|
||||||
mod # must be registered before exec so Flask(__name__) resolves root_path correctly
|
|
||||||
)
|
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
mounts[f"/{app_dir.name}"] = mod.app
|
mounts[f"/{category}/{app_dir.name}"] = mod.app
|
||||||
print(f" mounted /{app_dir.name}")
|
print(f" mounted /{category}/{app_dir.name}")
|
||||||
return mounts
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
51
uv.lock
generated
51
uv.lock
generated
|
|
@ -70,6 +70,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
|
|
@ -176,6 +185,11 @@ dependencies = [
|
||||||
{ name = "pypdf" },
|
{ name = "pypdf" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "flask", specifier = ">=3.0" },
|
{ name = "flask", specifier = ">=3.0" },
|
||||||
|
|
@ -184,6 +198,9 @@ requires-dist = [
|
||||||
{ name = "pypdf", specifier = ">=4.0" },
|
{ name = "pypdf", specifier = ">=4.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.1"
|
version = "26.1"
|
||||||
|
|
@ -193,6 +210,24 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymongo"
|
name = "pymongo"
|
||||||
version = "4.17.0"
|
version = "4.17.0"
|
||||||
|
|
@ -263,6 +298,22 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.8"
|
version = "3.1.8"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue