From ff2eaeb6c45f9da3ca362feb579242613647c9c2 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 16 May 2026 20:12:48 +0000 Subject: [PATCH] feat(home): multi-file upload + per-file analysis, drop tool grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home is now upload + analysis only. The page accepts multiple files in one go, analyzes each independently, and renders findings grouped by filename in bordered containers. The 3-section tool-card grid is gone — discovery happens via the sidebar now. Mechanics: - file_uploader uses accept_multiple_files=True. Each file's findings cache in session_state["home_findings_by_file"] keyed by filename so removing a file via Streamlit's "x" button drops its findings too, and re-clicking Run only re-analyzes pending files. - The first uploaded file is mirrored into the singular home_uploaded_{name,bytes,size} keys so tool pages continue to pick up an "active" upload through pickup_or_upload — no tool-page changes. - New i18n keys: upload.intro_multi, upload.uploader_label_multi, upload.clear_results, upload.empty_state. upload.heading text is updated to "Upload one or more files to start" (EN + ES). Dropped tests pinning the tool grid: - TestHomeToolGridLocalization (test_chrome.py) - test_home_tool_card_uses_es_name (test_smoke.py) - TestLiteHomeGridBadges (test_lite_tier.py — locked-card lock-badge assertions; locking is still enforced per-tool-page via require_feature_or_render_upgrade) 2009 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gui/app.py | 158 +++++++++++++++++++----------------- src/i18n/packs/en.json | 8 +- src/i18n/packs/es.json | 8 +- tests/gui/test_chrome.py | 26 +----- tests/gui/test_lite_tier.py | 30 ------- tests/gui/test_smoke.py | 9 -- 6 files changed, 99 insertions(+), 140 deletions(-) diff --git a/src/gui/app.py b/src/gui/app.py index 8166dc1..1170dcc 100644 --- a/src/gui/app.py +++ b/src/gui/app.py @@ -30,20 +30,10 @@ if str(_project_root) not in sys.path: # --------------------------------------------------------------------------- def _home_page() -> None: - """Render the home page — upload section + tool grid + footer.""" - from src.gui.components import ( - findings_count_for_tool, - hide_streamlit_chrome, - upload_and_analyze_section, - ) - from src.gui.tools_registry import ( - TOOLS, - section_label, - tool_description, - tool_name, - ) + """Render the home page — multi-file upload + per-file analysis.""" + from src.gui.components import hide_streamlit_chrome, render_findings_panel + from src.gui.components._legacy import _run_analysis_on_upload from src.i18n import t - from src.license import get_manager st.set_page_config( page_title=t("home.page_title"), @@ -54,73 +44,95 @@ def _home_page() -> None: st.title(t("home.title")) st.caption(t("home.caption")) - st.divider() - upload_and_analyze_section() + st.markdown(f"### {t('upload.heading')}") + st.caption(t("upload.intro_multi")) - st.divider() - - # Per-tier feature set for badging — Ready tools that the user's - # tier doesn't unlock get a red "🔒 Locked" pill instead of the green - # "Ready" so they see at a glance which tools an upgrade would - # unlock. Dev-mode skips the lock check so the home grid renders the - # full SKU during development. - _license_state = get_manager().current_state() - _unlocked_features = ( - set(_license_state.features) if _license_state.valid else set() + uploaded_files = st.file_uploader( + t("upload.uploader_label_multi"), + type=["csv", "tsv", "xlsx", "xls"], + accept_multiple_files=True, + key="home_upload", + help=t("upload.uploader_help"), ) - _dev_mode = get_manager().dev_mode - # Group tool cards by sidebar section so the home grid mirrors the - # left-nav layout — same vocabulary, same ordering. - sections: list[tuple[str, list]] = [] - for section in ("cleaners", "transformations", "automations"): - tools = [tool for tool in TOOLS if tool.section == section] - if not tools: - continue - sections.append((section_label(section), tools)) + if not uploaded_files: + st.info(t("upload.empty_state")) + return - for header, tools in sections: - st.subheader(header) - for row_start in range(0, len(tools), 3): - cols = st.columns(3) - for i, col in enumerate(cols): - idx = row_start + i - if idx >= len(tools): - break - tool = tools[idx] - with col: - tool_locked = ( - tool.status == "Ready" - and not _dev_mode - and tool.tool_id not in _unlocked_features - ) - if tool_locked: - status_key = "license.status_locked" - status_color = "red" - elif tool.status == "Ready": - status_key = "status.ready" - status_color = "green" - else: - status_key = "status.coming_soon" - status_color = "orange" + # Keep tool pages working: they consume a single ``home_uploaded_*`` + # set via ``pickup_or_upload``. Expose the first uploaded file as + # the "active" upload for that contract; the rest live alongside + # for per-file analysis on this page. + first = uploaded_files[0] + if ( + st.session_state.get("home_uploaded_name") != first.name + or st.session_state.get("home_uploaded_size") != first.size + ): + st.session_state["home_uploaded_name"] = first.name + st.session_state["home_uploaded_size"] = first.size + st.session_state["home_uploaded_bytes"] = first.getvalue() - badge = "" - n = findings_count_for_tool(tool.tool_id) - if n: - badge_key = ( - "home.findings_badge_one" - if n == 1 - else "home.findings_badge_other" - ) - badge = f" :red-background[**{t(badge_key, n=n)}**]" - lock_glyph = "🔒 " if tool_locked else "" - st.markdown( - f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n" - f"{tool_description(tool.tool_id)}\n\n" - f":{status_color}[**{lock_glyph}{t(status_key)}**]" - ) + # Per-file findings live in a dict so removing a file from the + # uploader (Streamlit's "x" button) drops its results too. We only + # re-analyze files we haven't already analyzed in this session. + findings_by_file: dict = st.session_state.setdefault( + "home_findings_by_file", {} + ) + current_names = {f.name for f in uploaded_files} + findings_by_file = { + name: result for name, result in findings_by_file.items() + if name in current_names + } + st.session_state["home_findings_by_file"] = findings_by_file + + pending = [f for f in uploaded_files if f.name not in findings_by_file] + + col_run, col_clear, _ = st.columns([1, 1, 4]) + with col_run: + run_clicked = st.button( + t("upload.run_button"), + type="primary", + key="home_run_analysis", + disabled=not pending, + use_container_width=True, + ) + with col_clear: + clear_clicked = st.button( + t("upload.clear_results"), + key="home_clear_results", + disabled=not findings_by_file, + use_container_width=True, + ) + + if clear_clicked: + st.session_state["home_findings_by_file"] = {} + st.rerun() + + if run_clicked: + progress = st.progress(0.0, text=t("upload.scanning")) + for i, f in enumerate(pending, start=1): + findings_by_file[f.name] = _run_analysis_on_upload(f) + progress.progress(i / len(pending), text=f"{f.name}") + st.session_state["home_findings_by_file"] = findings_by_file + progress.empty() + st.rerun() + + if findings_by_file: + st.divider() + # Preserve uploader order so the user sees results in the same + # order they appear in the file list above. + for f in uploaded_files: + if f.name not in findings_by_file: + continue + findings = findings_by_file[f.name] + with st.container(border=True): + if not findings: + st.markdown(f"### 📄 {f.name}") + st.success(t("findings.none")) + else: + render_findings_panel(findings, header=f"📄 {f.name}") st.divider() st.caption(t("chrome.footer")) diff --git a/src/i18n/packs/en.json b/src/i18n/packs/en.json index e0b3acd..8cabe77 100644 --- a/src/i18n/packs/en.json +++ b/src/i18n/packs/en.json @@ -15,7 +15,7 @@ "coming_soon": "Coming Soon" }, "upload": { - "heading": "📤 Upload a file to start", + "heading": "📤 Upload one or more files to start", "intro": "Optional: scan an uploaded file for data quality issues and see which tools can fix each one. Skip if you already know what you need.", "limits": "**Up to 1.5 GB.** Formats: CSV, TSV, XLSX, XLS. Delimiters auto-detected: comma, tab, semicolon, pipe. Encodings auto-detected: UTF-8 (with/without BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — and override on the Review page.", "uploader_label": "Upload CSV or Excel", @@ -27,7 +27,11 @@ "using_session_file": "Using **{name}** from the upload screen.", "use_different_file": "Use a different file", "switch_back": "Switch back to upload-screen file", - "pickup_caption": "Up to 1.5 GB. Delimiters auto-detected: comma, tab, semicolon, pipe. Encoding auto-detected (UTF-8 / UTF-16 / cp1252 / Latin-1 family / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), with override on the Review page." + "pickup_caption": "Up to 1.5 GB. Delimiters auto-detected: comma, tab, semicolon, pipe. Encoding auto-detected (UTF-8 / UTF-16 / cp1252 / Latin-1 family / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), with override on the Review page.", + "intro_multi": "Drop files below. Each one is analyzed locally — no upload anywhere.", + "uploader_label_multi": "Upload CSV, TSV, or Excel files", + "clear_results": "Clear results", + "empty_state": "Upload one or more files to begin. Your data never leaves this computer." }, "findings": { "header": "Detected issues", diff --git a/src/i18n/packs/es.json b/src/i18n/packs/es.json index fed3af6..95cdaec 100644 --- a/src/i18n/packs/es.json +++ b/src/i18n/packs/es.json @@ -15,7 +15,7 @@ "coming_soon": "Próximamente" }, "upload": { - "heading": "📤 Sube un archivo para empezar", + "heading": "📤 Sube uno o más archivos para empezar", "intro": "Opcional: analiza un archivo para detectar problemas de calidad de datos y ver qué herramientas pueden corregir cada uno. Sáltalo si ya sabes lo que necesitas.", "limits": "**Hasta 1,5 GB.** Formatos: CSV, TSV, XLSX, XLS. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificaciones detectadas automáticamente: UTF-8 (con/sin BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — y se pueden sustituir desde la página Revisar.", "uploader_label": "Sube un archivo CSV o Excel", @@ -27,7 +27,11 @@ "using_session_file": "Usando **{name}** de la pantalla de carga.", "use_different_file": "Usar otro archivo", "switch_back": "Volver al archivo de la pantalla de carga", - "pickup_caption": "Hasta 1,5 GB. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificación detectada automáticamente (UTF-8 / UTF-16 / cp1252 / familia Latin-1 / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), con opción de sustituirla en la página Revisar." + "pickup_caption": "Hasta 1,5 GB. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificación detectada automáticamente (UTF-8 / UTF-16 / cp1252 / familia Latin-1 / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), con opción de sustituirla en la página Revisar.", + "intro_multi": "Suelta archivos abajo. Cada uno se analiza localmente — no se sube a ningún lado.", + "uploader_label_multi": "Sube archivos CSV, TSV o Excel", + "clear_results": "Borrar resultados", + "empty_state": "Sube uno o más archivos para empezar. Tus datos nunca salen de este equipo." }, "findings": { "header": "Problemas detectados", diff --git a/tests/gui/test_chrome.py b/tests/gui/test_chrome.py index 4340d15..dc9e516 100644 --- a/tests/gui/test_chrome.py +++ b/tests/gui/test_chrome.py @@ -114,8 +114,8 @@ class TestLocalizedChrome: with_language(home_app, "es") home_app.run() text = collected_text(home_app) - # ``📤 Sube un archivo para empezar`` from the es pack. - assert "Sube un archivo" in text + # ``📤 Sube uno o más archivos para empezar`` from the es pack. + assert "Sube uno o más archivos" in text # --------------------------------------------------------------------------- @@ -157,25 +157,3 @@ class TestQuitButtonRenders: assert "trabajo sin guardar" in text -# --------------------------------------------------------------------------- -# Tool cards use localized names on the home grid -# --------------------------------------------------------------------------- - -class TestHomeToolGridLocalization: - """The home grid pulls tool display names through ``tool_name()`` in - ``tools_registry``. The Spanish pack provides translations for every - tool id; a regression in that wiring would make Spanish users see - English names. Pin a few representative ones.""" - - @pytest.mark.parametrize("needle", [ - "Buscar duplicados", - "Limpiar texto", - "Estandarizar formatos", - "Corregir valores faltantes", - "Mapear columnas", - ]) - def test_es_tool_name_on_home_grid(self, home_app, needle): - with_language(home_app, "es") - home_app.run() - text = collected_text(home_app) - assert needle in text, f"missing localized tool name {needle!r}" diff --git a/tests/gui/test_lite_tier.py b/tests/gui/test_lite_tier.py index 7e08301..4a0bf45 100644 --- a/tests/gui/test_lite_tier.py +++ b/tests/gui/test_lite_tier.py @@ -94,36 +94,6 @@ class TestLiteLockedPages: ) -# --------------------------------------------------------------------------- -# Home grid shows lock badges -# --------------------------------------------------------------------------- - -class TestLiteHomeGridBadges: - def test_locked_tool_card_shows_lock_badge( - self, lite_license, home_app, - ): - home_app.run() - text = collected_text(home_app) - # Fix Missing Values is locked under Lite — its card should - # have a 🔒 Locked badge. - # We assert the lock glyph appears alongside the locked tool's - # display name. Streamlit renders the markdown verbatim so the - # ``🔒 Locked`` text appears in the page markdown stream. - assert "🔒" in text or "Locked" in text, ( - "home grid missing lock badge for Lite-locked tool" - ) - - def test_unlocked_tool_card_no_lock(self, lite_license, home_app): - home_app.run() - # Dedup is unlocked under Lite. Its card markdown should NOT - # contain a lock glyph adjacent to its name. We can't easily - # scope by card without parsing the markdown stream, but we - # can confirm both ``Ready`` (unlocked) and ``Locked`` - # (locked) badges coexist on the page. - text = collected_text(home_app) - assert "Ready" in text or "Listo" in text - - # --------------------------------------------------------------------------- # Sidebar status shows Lite # --------------------------------------------------------------------------- diff --git a/tests/gui/test_smoke.py b/tests/gui/test_smoke.py index 7fa8c10..94038e9 100644 --- a/tests/gui/test_smoke.py +++ b/tests/gui/test_smoke.py @@ -87,15 +87,6 @@ class TestHomePageRenders: text = collected_text(home_app) assert "Tus datos nunca salen" in text or "Se ejecuta localmente" in text - def test_home_tool_card_uses_es_name(self, home_app): - """When the home grid renders in Spanish, the dedup card title - must use the Spanish display name, not the English fallback.""" - with_language(home_app, "es") - home_app.run() - text = collected_text(home_app) - assert "Buscar duplicados" in text - - class TestEveryPageRenders: """Parametrize over (page, language). Failure tells you exactly which page + which language broke."""