change structure

This commit is contained in:
Connor Rhodes 2026-04-24 08:52:01 -05:00
parent 2d61260583
commit 4f4fe1eff2
11 changed files with 126 additions and 726 deletions

View file

@ -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>

View file

@ -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)

View file

@ -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>

View file

@ -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)

View file

@ -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

View file

@ -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

View file

View file

@ -8,3 +8,8 @@ dependencies = [
"pymongo>=4.0",
"pypdf>=4.0",
]
[dependency-groups]
dev = [
"pytest>=9.0.3",
]

View file

@ -6,24 +6,52 @@ from flask import Flask, render_template_string
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from werkzeug.serving import run_simple
CATEGORIES = ["personal", "home", "work"]
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("/")
def index():
apps = [
d.name
for d in Path(__file__).parent.iterdir()
if d.is_dir() and (d / "app.py").exists()
]
items = "".join(
f'<a href="/{a}" class="app-item" style="animation-delay:{i * 50}ms">'
f'<span class="app-arrow">\u203a</span>'
f'<div class="app-name">{a.replace("_", " ").replace("-", " ").title()}</div>'
f'<div class="app-path">/{a}</div>'
f"</a>"
for i, a in enumerate(sorted(apps))
)
apps = _discover_apps()
grouped: dict[str, list[tuple[str, str]]] = {}
for category, app_name in apps:
grouped.setdefault(category, []).append((category, app_name))
sections = ""
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'<div class="app-name">{name.replace("_", " ").replace("-", " ").title()}</div>'
f'<div class="app-path">/{cat}/{name}</div>'
f"</a>"
)
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"""
<!DOCTYPE html>
<html lang="en">
@ -86,6 +114,20 @@ def index():
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 {{
display: flex;
flex-direction: column;
@ -154,7 +196,7 @@ def index():
<h1>Tools</h1>
<p class="subtitle">Flask applications</p>
</header>
<div class="app-list">{items}</div>
{sections}
</div>
</body>
</html>
@ -164,18 +206,21 @@ def index():
def load_sub_apps():
mounts = {}
base = Path(__file__).parent
for app_dir in sorted(base.iterdir()):
app_file = app_dir / "app.py"
if not app_dir.is_dir() or not app_file.exists():
for category in CATEGORIES:
cat_dir = base / category
if not cat_dir.is_dir():
continue
spec = importlib.util.spec_from_file_location(app_dir.name, app_file)
mod = importlib.util.module_from_spec(spec)
sys.modules[app_dir.name] = (
mod # must be registered before exec so Flask(__name__) resolves root_path correctly
)
spec.loader.exec_module(mod)
mounts[f"/{app_dir.name}"] = mod.app
print(f" mounted /{app_dir.name}")
for app_dir in sorted(cat_dir.iterdir()):
app_file = app_dir / "app.py"
if not app_dir.is_dir() or not app_file.exists():
continue
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)
sys.modules[module_name] = mod
spec.loader.exec_module(mod)
mounts[f"/{category}/{app_dir.name}"] = mod.app
print(f" mounted /{category}/{app_dir.name}")
return mounts

51
uv.lock generated
View file

@ -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" },
]
[[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]]
name = "itsdangerous"
version = "2.2.0"
@ -176,6 +185,11 @@ dependencies = [
{ name = "pypdf" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.0" },
@ -184,6 +198,9 @@ requires-dist = [
{ name = "pypdf", specifier = ">=4.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "packaging"
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" },
]
[[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]]
name = "pymongo"
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" },
]
[[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]]
name = "werkzeug"
version = "3.1.8"