Compare commits

...

3 commits

12 changed files with 825 additions and 1353 deletions

View file

@ -1,14 +1,17 @@
list: list:
just --list just --list
#run: run *args:
# uv run carousel uv run src/carouselcmd.py {{args}}
python *arguments: python *arguments:
uv run python -c {{arguments}} uv run python -c {{arguments}}
test: marimo:
uv run pytest -vvv --tb=short --log-cli-level=INFO uv run marimo --edit
sync *args:
uv sync {{args}}
format: format:
uv run ruff format src test uv run ruff format src test
@ -16,22 +19,21 @@ format:
check: check:
uv run pyright src uv run pyright src
sync: test:
uv sync --upgrade uv run pytest -vvv --tb=short --log-cli-level=INFO
lock: compile:
uv pip compile pyproject.toml -o requirements.txt --group dev uv run --with-requirements src/carouselcmd.py pyinstaller --clean -F src/carouselcmd.py
build:
uv run pyinstaller src/cli.py
uv run pyinstaller src/gui.py
clean: clean:
uv run pyclean src test uv run pyclean src test
uv run ruff clean uv run ruff clean
rm -rf main.spec cli.spec gui.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__ rm -rf carouselcmd.spec carouselgui.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__
wipe: wipe:
just clean just clean
rm -rf .venv rm -rf .venv
lock:
uv lock
uv pip compile pyproject.toml -o requirements.txt --group dev

View file

@ -6,19 +6,17 @@ readme = "README.md"
authors = [{ name = "Thomas (Tom) C. Gorordo", email = "tcgorordo@gmail.com" }] authors = [{ name = "Thomas (Tom) C. Gorordo", email = "tcgorordo@gmail.com" }]
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"click>=8.1.8",
"numpy>=2.2.4", "numpy>=2.2.4",
"polars>=1.26.0", "polars>=1.26.0",
"pyside6>=6.9.0", "rustworkx>=0.17.1",
"rich>=14.0.0",
] ]
[project.scripts] #[project.scripts]
carousel = "carousel:main" #carousel = "carousel:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["uv_build>=0.11.7,<0.12.0"]
build-backend = "hatchling.build" build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
@ -33,10 +31,7 @@ dev = [
"ruff>=0.11.2", "ruff>=0.11.2",
"ty>=0.0.0a5", "ty>=0.0.0a5",
] ]
srv = [
"legacy-cgi>=2.6.3",
"python-dotenv>=1.1.0",
]
[pytest] [pytest]
testpaths = "test" testpaths = "test"
@ -45,6 +40,3 @@ log_cli = true
[tool.pyright] [tool.pyright]
include = ["src"] include = ["src"]
exclude = ["test"] exclude = ["test"]
reportMissingImports = "error"
reportMissingTypeStubs = false

View file

@ -22,7 +22,6 @@ certifi==2025.8.3
# httpx # httpx
click==8.1.8 click==8.1.8
# via # via
# carousel (pyproject.toml)
# marimo # marimo
# uvicorn # uvicorn
distro==1.9.0 distro==1.9.0
@ -75,12 +74,8 @@ markdown==3.9
# via # via
# marimo # marimo
# pymdown-extensions # pymdown-extensions
markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2 markupsafe==3.0.2
# via jinja2 # via jinja2
mdurl==0.1.2
# via markdown-it-py
narwhals==2.3.0 narwhals==2.3.0
# via # via
# altair # altair
@ -90,7 +85,9 @@ nbformat==5.10.4
nodeenv==1.9.1 nodeenv==1.9.1
# via pyright # via pyright
numpy==2.2.4 numpy==2.2.4
# via carousel (pyproject.toml) # via
# carousel (pyproject.toml)
# rustworkx
openai==1.106.1 openai==1.106.1
# via marimo # via marimo
packaging==24.2 packaging==24.2
@ -123,9 +120,7 @@ pydantic==2.11.7
pydantic-core==2.33.2 pydantic-core==2.33.2
# via pydantic # via pydantic
pygments==2.19.1 pygments==2.19.1
# via # via marimo
# marimo
# rich
pyinstaller==6.13.0 pyinstaller==6.13.0
# via carousel (pyproject.toml:dev) # via carousel (pyproject.toml:dev)
pyinstaller-hooks-contrib==2025.3 pyinstaller-hooks-contrib==2025.3
@ -134,14 +129,6 @@ pymdown-extensions==10.16.1
# via marimo # via marimo
pyright==1.1.399 pyright==1.1.399
# via carousel (pyproject.toml:dev) # via carousel (pyproject.toml:dev)
pyside6==6.9.0
# via carousel (pyproject.toml)
pyside6-addons==6.9.0
# via pyside6
pyside6-essentials==6.9.0
# via
# pyside6
# pyside6-addons
pytest==8.3.5 pytest==8.3.5
# via # via
# carousel (pyproject.toml:dev) # carousel (pyproject.toml:dev)
@ -156,8 +143,6 @@ referencing==0.36.2
# via # via
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
rich==14.0.0
# via carousel (pyproject.toml)
rpds-py==0.27.1 rpds-py==0.27.1
# via # via
# jsonschema # jsonschema
@ -166,15 +151,12 @@ ruff==0.11.6
# via # via
# carousel (pyproject.toml:dev) # carousel (pyproject.toml:dev)
# marimo # marimo
rustworkx==0.17.1
# via carousel (pyproject.toml)
setuptools==78.1.0 setuptools==78.1.0
# via # via
# pyinstaller # pyinstaller
# pyinstaller-hooks-contrib # pyinstaller-hooks-contrib
shiboken6==6.9.0
# via
# pyside6
# pyside6-addons
# pyside6-essentials
sniffio==1.3.1 sniffio==1.3.1
# via # via
# anyio # anyio

45
src/carouselcmd.py Normal file
View file

@ -0,0 +1,45 @@
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "click>=8.4.1",
# "rich>=15.0.0",
# "polars>=1.40.1",
# "rustworkx>=0.17.1"
# ]
# ///
import sys, io
import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
import polars as pl
from carousel import match_from_prefs
@click.command()
@click.argument("preferences", type=click.Path(exists=True, dir_okay=False))
@click.option(
"--show-preferences",
"-b",
is_flag=True,
help="Show relevant preferences (after selections).",
)
@click.option("--pretty", "-p", is_flag=True, help="Pretty-print output.")
def cli(preferences: str, show_ballots=False, pretty=False) -> None:
"""
Compute the Gale-Shapley stable match of some preferences -- .csv or .xls(x).
A stable matching (of the "college admissions" problem) is one in which there is no
pair of, say, TA and course which would prefer each other over their respective
assignment given by the matching.
"""
console = Console()
pass
if __name__ == "__main__":
cli()

12
src/cgi/carousel.cgi Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ "$QUERY_STRING" == "source" ]]; then
echo "Content-Type: text/plain"
echo
sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' "$0"
exit 0
fi
exec "$SCRIPT_DIR/../../.venv/bin/python" "$SCRIPT_DIR/script.py"

View file

@ -1,24 +0,0 @@
#!/usr/bin/env -S uv run python
import os
from dotenv import load_dotenv
load_dotenv()
logdir = os.getenv("CAROUSEL_LOGDIR")
import cgi, cgitb
cgitb.enable(display=0, logdir=logdir)
form = cgi.FieldStorage()
from ..carousel import *
print("Content-Type: text/html")
print()
print("<title>Carousel Matches!</title>")
print("<h1>Carousel Stable Matches Found:</h1>")
print("TODO - INPUTH ECHO NOT IMPLEMENTED")
print("TODO - OUTPUT NOT IMPLEMENTED")

View file

@ -1 +0,0 @@
CAROUSEL_LOGDIR="$HOME/logs/carousel/"

View file

@ -1,663 +1,75 @@
<!DOCTYPE html> <!DOCTYPE html>
/* Disclaimer: This file was generated using an LLM */
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Data Entry Form</title> <title> Carousel - Stable Matcher </title>
<style> <style>
body { body {
font-family: Georgia, serif; font-family: Arial, sans-serif;
line-height: 1.6; max-width: 600px;
color: #1a1a1a; margin: 40px auto;
max-width: 1200px;
margin: 0 auto;
padding: 20px; padding: 20px;
background-color: #f8f8f8;
} }
h1 { h1 {
color: #333; font-size: 24px;
text-align: center; margin-bottom: 20px;
border-bottom: 1px solid #ddd;
padding-bottom: 15px;
font-weight: normal;
} }
label {
h2 { display: block;
color: #333; margin-bottom: 8px;
font-weight: normal;
} }
input[type="file"] {
.container {
background: #fff;
border-radius: 3px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section {
margin-bottom: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px; margin-bottom: 15px;
} }
input[type="submit"] {
.controls-container { padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.controls {
display: flex;
gap: 10px;
}
.fill-default-control {
display: flex;
align-items: center;
margin-top: 5px;
}
.fill-default-btn {
background: #777;
margin-right: 10px;
}
.fill-default-btn:hover {
background: #555;
}
.disabled-table {
opacity: 0.5;
pointer-events: none;
}
button {
background: #555;
color: white;
border: none;
padding: 8px 15px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
font-family: Georgia, serif;
}
button:hover {
background: #333;
}
button.secondary {
background: #999;
}
button.secondary:hover {
background: #777;
}
.table-container {
overflow-x: auto;
margin-bottom: 20px;
}
table {
border-collapse: collapse;
width: 100%;
min-width: 600px;
border: 1px solid #ddd;
}
th, td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}
th {
background-color: #f5f5f5;
position: relative;
}
th input {
width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #ccc;
border-radius: 2px;
font-family: Georgia, serif;
}
td input {
width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #ccc;
border-radius: 2px;
font-family: Georgia, serif;
}
.file-upload {
margin-bottom: 20px;
}
.file-upload label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.file-info {
margin-top: 5px;
font-size: 14px;
color: #666;
}
.submit-section {
text-align: center;
border-bottom: none;
}
.submit-button {
background: #444;
color: white;
border: none;
padding: 10px 20px;
border-radius: 3px;
cursor: pointer;
font-size: 16px;
transition: background 0.3s;
font-family: Georgia, serif;
}
.submit-button:hover {
background: #222;
}
.progress-bar {
height: 5px;
background-color: #f1f1f1;
margin-top: 10px;
border-radius: 2px;
overflow: hidden;
display: none;
}
.progress {
height: 100%;
background-color: #666;
width: 0%;
transition: width 0.3s;
}
.message {
margin-top: 15px;
padding: 10px;
border-radius: 3px;
display: none;
}
.success {
background-color: #f1f5f1;
border: 1px solid #ddd;
color: #333;
}
.error {
background-color: #f5f1f1;
border: 1px solid #ddd;
color: #333;
}
.tabs {
display: flex;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
background: #f0f0f0;
border: 1px solid #ddd;
border-bottom: none;
border-radius: 3px 3px 0 0;
margin-right: 5px;
}
.tab.active {
background: #fff;
border-bottom: 1px solid #fff;
position: relative;
top: 1px;
}
.tab-content {
display: none;
border-top: 1px solid #ddd;
padding-top: 20px;
}
.tab-content.active {
display: block;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 20px;
height: 20px;
border-radius: 50%;
border-left-color: #555;
animation: spin 1s linear infinite;
display: inline-block;
vertical-align: middle;
margin-right: 10px;
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
} }
</style> </style>
</head> </head>
<body> <body>
(<a href="https://pages.uoregon.edu/tgorordo">Back to ~/tgorordo</a>) <h1>Carousel: Stable Matcher - CGI Application Upload</h1>
<div class="container">
<h1>Carousel Stable Matcher</h1>
<div class="tabs">
<div class="tab active" id="table-tab">Table Input</div>
<div class="tab" id="file-tab">File Upload</div>
</div>
<form id="data-form" action="/cgi-bin/carousel.py" method="post" enctype="multipart/form-data">
<div class="tab-content active" id="table-content">
<!-- First Table Section -->
<div class="form-section">
<div class="form-header">
<h2>Applicant Preferences</h2>
<div class="controls">
<button type="button" id="add-column-1">Add Column</button>
<button type="button" id="add-row-1">Add Row</button>
<button type="button" class="secondary" id="remove-column-1">Remove Column</button>
<button type="button" class="secondary" id="remove-row-1">Remove Row</button>
</div>
</div>
<div class="table-container">
<table id="data-table-1">
<thead>
<tr id="header-row-1">
<th><input type="text" placeholder="Applicant 1" name="table1_header_0" required></th>
<th><input type="text" placeholder="Applicant 2" name="table1_header_1" required></th>
<th><input type="text" placeholder="Applicant 3" name="table1_header_2" required></th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="text" placeholder="Preference 1..." name="table1_row_0_col_0"></td>
<td><input type="text" placeholder="Preference 1..." name="table1_row_0_col_1"></td>
<td><input type="text" placeholder="Preference 1..." name="table1_row_0_col_2"></td>
</tr>
<tr>
<td><input type="text" name="table1_row_1_col_0"></td>
<td><input type="text" name="table1_row_1_col_1"></td>
<td><input type="text" name="table1_row_1_col_2"></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Second Table Section -->
<div class="form-section">
<div class="form-header">
<h2>Reviewer Preferences</h2>
<div class="controls-container">
<div class="controls">
<button type="button" id="add-column-2">Add Column</button>
<button type="button" id="add-row-2">Add Row</button>
<button type="button" class="secondary" id="remove-column-2">Remove Column</button>
<button type="button" class="secondary" id="remove-row-2">Remove Row</button>
</div>
<div class="fill-default-control">
<label for="fill-default">
<button type="button" id="fill-default-btn" class="fill-default-btn">Fill Default</button>
<input type="checkbox" id="fill-default" name="fill_default"> (Use default uniform preferences.)
</label>
</div>
</div>
</div>
<div class="table-container" id="table2-container">
<table id="data-table-2">
<thead>
<tr id="header-row-2">
<th><input type="text" placeholder="Reviewer 1" name="table2_header_0" required></th>
<th><input type="text" placeholder="Reviewer 2" name="table2_header_1" required></th>
<th><input type="text" placeholder="Reviewer 3" name="table2_header_2" required></th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="text" placeholder="Preference 1..." name="table2_row_0_col_0"></td>
<td><input type="text" placeholder="Preference 1..." name="table2_row_0_col_1"></td>
<td><input type="text" placeholder="Preference 1..." name="table2_row_0_col_2"></td>
</tr> <div class="form-container">
<tr> <form action="./carousel.cgi" method="POST" enctype="multipart/form-data">
<td><input type="text" name="table2_row_1_col_0"></td> <label for="spreadsheet">Upload an assignment preference spreadsheet (.xlsx, .xls, .csv):</label>
<td><input type="text" name="table2_row_1_col_1"></td> <input type="file" id="spreadsheet" name="spreadsheet" accept=".xlsx,.xls,.csv" required>
<td><input type="text" name="table2_row_1_col_2"></td> <br>
</tr> <input type="submit" value="Upload and Match!">
</tbody> </form>
</table> </div>
</div>
</div>
</div>
<div class="tab-content" id="file-content">
<div class="form-section">
<div class="file-upload">
<label for="file-input">Upload a CSV or Excel file:</label>
<input type="file" id="file-input" name="file" accept=".csv,.xlsx,.xls">
<div class="file-info" id="file-info"></div>
</div>
</div>
</div>
<div class="form-section submit-section">
<button type="submit" class="submit-button">
<span id="spinner" class="spinner"></span>
<span id="submit-text">Submit Preferences</span>
</button>
<div class="progress-bar" id="progress-bar">
<div class="progress" id="progress"></div>
</div>
<div class="message" id="message"></div>
</div>
<div class="container"> <div class="explanation-container">
<details>
<summary><b>Input/Output Format Documentation</b></summary>
<p>
The matcher assumes a specific tabular scheme for the input data - to read more about how this works see the
<a href="https://github.com/tgorordo/carousel">README</a>.
You can enter preference information directly in the tables above, or upload an Excel spreadsheet with <h2>About</h2>
two sheets of preferences, or a CSV containing a ranking matrix. <p>This is an upload form for the <a href="https://github.com/tgorordo/carousel">carousel</a> stable matcher for ,
</p> More on each format scheme: mainly to help with <a href="https://blogs.uoregon.edu/physicsgsg/">UO Physics TA assignments</a>.
This form uses the <a href="https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069">UO pages.uoregon.edu CGI Capability</a>,
so the implementation of smithy being invoked can be <a href="https://pages.uoregon.edu/tgorordo/files/carousel/"> inspected here</a>
and you may also inspect the source of this page to verify that <a href="https://pages.uoregon.edu/tgorordo/files/carousel/src/cgi/carousel.cgi?source">this script<a> is called to invoke it - though
you have to trust it to <a href="https://en.wikipedia.org/wiki/Quine_(computing)">quine</a> itself faithfully.
</p>
<p> A <a href=https://en.wikipedia.org/wiki/Stable_matching_problem>stable matching (of the "college admissions" problem)</a> is one in which there is no pair of, say, TA and course which would prefer
each other over their respective assignment given by the matching. This form runs a version of the
<a href="https://en.wikipedia.org/wiki/Gale%E2%80%93Shapley_algorithm">Gale-Shapley (1962) deferred-acceptance algorithm</a>, which finds the stable matching
that is optimal for the TA preferrences. Other stable solutions can be found by using <a href="https://github.com/tgorordo/carousel">the underlying
python application manually</a>.
</p>
<h3>Input: Expected Spreadsheet Format</h3>
<h3>Tabular Input</h3> <p>A matching-preference spreadsheet should be organized as a list of TA assignment preferences
You can enter tables of preferences directly in the first tab of the form above. and a list of course preferences/constraints, i.e. with the following structure:
TODO</p>
TODO
<h3>Output: Matching Format</h3>
<h4>Example:</h4> <p>The form will return a matching of TAs to course assignments of the form: TODO</p>
Suppose we want to figure out a Grad TA-to-Class assignment. Graduate students will be our "Applicants" </div>
- each one will be assigned a class to TA -, and classes will be our "Reviewers" - taking a quota of needed TAs.
TODO
<h3>File Upload</h3>
For more complicated input, a file upload might be preferable to table form submission
(e.g. if there's a validation issue you won't have to manually re-enter the table, just tweak and re-upload the file).
Two file formats are supported, Excel and CSV:
<h4>Excel</h4>
TODO
<h4>CSV</h4>
TODO
</details>
</div>
</form>
</div>
<script>
// Tab switching functionality
document.getElementById('table-tab').addEventListener('click', () => {
switchTab('table');
});
document.getElementById('file-tab').addEventListener('click', () => {
switchTab('file');
});
function switchTab(tabName) {
// Hide all tabs and contents
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// Show the selected tab and content
document.getElementById(`${tabName}-tab`).classList.add('active');
document.getElementById(`${tabName}-content`).classList.add('active');
}
// Table manipulation functions
function setupTableControls(tableNumber) {
const addColumnBtn = document.getElementById(`add-column-${tableNumber}`);
const addRowBtn = document.getElementById(`add-row-${tableNumber}`);
const removeColumnBtn = document.getElementById(`remove-column-${tableNumber}`);
const removeRowBtn = document.getElementById(`remove-row-${tableNumber}`);
const dataTable = document.getElementById(`data-table-${tableNumber}`);
const headerRow = document.getElementById(`header-row-${tableNumber}`);
addColumnBtn.addEventListener('click', () => {
const colCount = headerRow.cells.length;
// Add header cell
const newHeader = document.createElement('th');
newHeader.innerHTML = `<input type="text" placeholder="Column ${colCount + 1}" name="table${tableNumber}_header_${colCount}" required>`;
headerRow.appendChild(newHeader);
// Add cell to each row
const rows = dataTable.querySelectorAll('tbody tr');
rows.forEach((row, rowIndex) => {
const newCell = document.createElement('td');
newCell.innerHTML = `<input type="text" name="table${tableNumber}_row_${rowIndex}_col_${colCount}">`;
row.appendChild(newCell);
});
});
addRowBtn.addEventListener('click', () => {
const tbody = dataTable.querySelector('tbody');
const rows = tbody.querySelectorAll('tr');
const rowCount = rows.length;
const colCount = headerRow.cells.length;
// Create new row
const newRow = document.createElement('tr');
// Add cells to the new row
for (let i = 0; i < colCount; i++) {
const newCell = document.createElement('td');
newCell.innerHTML = `<input type="text" name="table${tableNumber}_row_${rowCount}_col_${i}">`;
newRow.appendChild(newCell);
}
tbody.appendChild(newRow);
});
removeColumnBtn.addEventListener('click', () => {
const colCount = headerRow.cells.length;
if (colCount <= 1) {
alert('Table must have at least one column.');
return;
}
// Remove the last header cell
headerRow.deleteCell(colCount - 1);
// Remove the last cell from each row
const rows = dataTable.querySelectorAll('tbody tr');
rows.forEach(row => {
row.deleteCell(colCount - 1);
});
});
removeRowBtn.addEventListener('click', () => {
const tbody = dataTable.querySelector('tbody');
const rows = tbody.querySelectorAll('tr');
if (rows.length <= 1) {
alert('Table must have at least one row.');
return;
}
// Remove the last row
tbody.removeChild(rows[rows.length - 1]);
});
}
// Setup controls for both tables
setupTableControls(1);
setupTableControls(2);
// Fill Default functionality for Table 2
const fillDefaultCheckbox = document.getElementById('fill-default');
const fillDefaultBtn = document.getElementById('fill-default-btn');
const table2Container = document.getElementById('table2-container');
fillDefaultBtn.addEventListener('click', () => {
fillDefaultCheckbox.checked = !fillDefaultCheckbox.checked;
toggleTable2State();
});
fillDefaultCheckbox.addEventListener('change', toggleTable2State);
function toggleTable2State() {
if (fillDefaultCheckbox.checked) {
table2Container.classList.add('disabled-table');
} else {
table2Container.classList.remove('disabled-table');
}
}
// File upload handling
const fileInput = document.getElementById('file-input');
const fileInfo = document.getElementById('file-info');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
fileInfo.textContent = `Selected: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
} else {
fileInfo.textContent = '';
}
});
// Form submission
const dataForm = document.getElementById('data-form');
const submitButton = document.querySelector('.submit-button');
const spinner = document.getElementById('spinner');
const submitText = document.getElementById('submit-text');
const progressBar = document.getElementById('progress-bar');
const progress = document.getElementById('progress');
const message = document.getElementById('message');
dataForm.addEventListener('submit', (e) => {
e.preventDefault();
// Show spinner and disable button
spinner.style.display = 'inline-block';
submitButton.disabled = true;
submitText.textContent = 'Submitting...';
progressBar.style.display = 'block';
message.style.display = 'none';
message.className = 'message';
// Collect form data
const formData = new FormData(dataForm);
// Add a flag to indicate which tab is active
formData.append('input_type', document.getElementById('table-content').classList.contains('active') ? 'table' : 'file');
// If table input is active, add table structure info
if (document.getElementById('table-content').classList.contains('active')) {
// Table 1 info
const colCount1 = document.getElementById('header-row-1').cells.length;
const rowCount1 = document.getElementById('data-table-1').querySelectorAll('tbody tr').length;
formData.append('table1_col_count', colCount1);
formData.append('table1_row_count', rowCount1);
// Table 2 info
const colCount2 = document.getElementById('header-row-2').cells.length;
const rowCount2 = document.getElementById('data-table-2').querySelectorAll('tbody tr').length;
formData.append('table2_col_count', colCount2);
formData.append('table2_row_count', rowCount2);
formData.append('table2_use_default', document.getElementById('fill-default').checked);
}
// Simulate upload progress (in a real application, you'd track actual upload progress)
let width = 0;
const progressInterval = setInterval(() => {
if (width >= 90) {
clearInterval(progressInterval);
} else {
width += 5;
progress.style.width = width + '%';
}
}, 100);
// Send form data to server
fetch(dataForm.action, {
method: 'POST',
body: formData
})
.then(response => {
clearInterval(progressInterval);
progress.style.width = '100%';
if (!response.ok) {
throw new Error('Server error: ' + response.status);
}
return response.json();
})
.then(data => {
// Show success message
message.textContent = data.message || 'Data submitted successfully!';
message.classList.add('success');
message.style.display = 'block';
})
.catch(error => {
// Show error message
console.error('Error:', error);
message.textContent = 'An error occurred while submitting the data. Please try again.';
message.classList.add('error');
message.style.display = 'block';
})
.finally(() => {
// Hide spinner and re-enable button
setTimeout(() => {
spinner.style.display = 'none';
submitButton.disabled = false;
submitText.textContent = 'Submit Data';
progressBar.style.display = 'none';
}, 500);
});
});
</script>
</body> </body>
<footer>
<hr>
<p>Author: <a href="https://pages.uoregon.edu/tgorordo">Thomas (Tom) C. Gorordo</a>
Source: <a href="https://github.com/tgorordo/pages.uoregon.edu">pages.uoregon.edu/tgorordo</a>,
<a href="https://github.com/tgorordo/carousel">carousel</a></p>
</footer>
</html> </html>

145
src/cgi/script.py Normal file
View file

@ -0,0 +1,145 @@
import traceback
import sys, os
import html
import re
import polars as pl
sys.path.insert(
0,
os.path.abspath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "../carousel/src")
),
)
from carousel import match_from_prefs
print("Content-Type: text/html\n")
message = ""
content_type = os.environ.get("CONTENT_TYPE", "")
content_length = int(os.environ.get("CONTENT_LENGTH", 0) or 0)
boundary = content_type.split("boundary=")[-1].encode()
body = sys.stdin.buffer.read(content_length)
parts = body.split(b"--" + boundary)
spreadsheet = None
for part in parts:
if b"Content-Disposition" in part and b'name="spreadsheet"' in part:
header, _, data = part.partition(b"\r\n\r\n")
filename_match = re.search(rb'filename="([^"]+)"', header)
if filename_match:
filename = filename_match.group(1).decode()
filedata = data.rstrip(b"\r\n--")
spreadsheet = (filename, filedata)
break
if spreadsheet is not None:
filename, filedata = spreadsheet
if filename and filedata:
filepath = os.path.join("/tmp", filename)
with open(filepath, "wb") as f:
f.write(filedata)
try:
if filename.endswith(".csv"):
df = pl.read_csv(filepath)
elif filename.endswith((".xlsx", ".xls")):
df = pl.read_excel(filepath)
else:
message = """
<h1>Error</h1>
<p>File extension is not valid. Use CSV (.csv) or Excel (.xlsx, .xls).</p>
<p><a href="form.html">Go Back</a></p>
"""
# TODO: unpack df appropriately
if df is not None:
# Normalize
df = df.with_columns(
[
pl.col(c)
.cast(pl.Utf8)
.str.strip_chars()
.cast(pl.Int64, strict=False)
.fill_null(0)
for c in df.columns
]
)
match = match_from_prefs(df) # Solve!
message = f"""
<h1>The TA-optimal (Gale-Shapley) match is:</h1>
{match}
<p><a href="form.html">Go Back</a></p>
"""
else:
message = """
<h1>Error</h1>
<p>DataFrame was empty.</p>
<p><a href="form.html">Go Back</a></p>
"""
except Exception as e:
message = f"""
<h1>Error</h1>
<p>Internal Error Encountered: {e}
<p><a href="form.html">Go Back</a></p>
"""
traceback.print_exc()
else:
message = """
<h1>Error</h1>
<p>Filename or File Data not found/valid in form submission.</p>
<p><a href="form.html">Go Back</a></p>
"""
else:
message = """
<h1>Error</h1>
<p>No file field found in the form.</p>
<p><a href="form.html">Go Back</a></p>
"""
print("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title> Carousel - Stable Matcher </title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 20px;
}
h1 {
font-size: 24px;
margin-bottom: 20px;
}
</style>
</head>
<body>
""")
print(message)
print("""
</body>
<footer>
<hr>
<p>Author: <a href="https://pages.uoregon.edu/tgorordo">Thomas (Tom) C. Gorordo</a>
Source: <a href="https://github.com/tgorordo/pages.uoregon.edu">pages.uoregon.edu/tgorordo</a>,
<a href="https://github.com/tgorordo/carousel">carousel</a></p>
</footer>
</html>
""")

View file

@ -1,11 +0,0 @@
import click
import carousel
@click.command()
def cli():
carousel.main()
if __name__ == "__main__":
cli()

1174
uv.lock generated

File diff suppressed because it is too large Load diff