From 67a2faa2d36aa06a1e62b71b0d038a66ae880a1b Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 6 Mar 2026 23:37:24 +0000 Subject: [PATCH] fix: separate days now are in separate .xlsx files, NRLs still 1 per sheet. add: rebuild script for prod. fix: Improved data parsing, now filters out unneeded Lp files and .xlsx files. --- backend/routers/project_locations.py | 24 +++ backend/routers/projects.py | 211 ++++++++++++++----------- rebuild-prod.sh | 12 ++ templates/combined_report_preview.html | 6 +- templates/nrl_detail.html | 113 +++++++++---- templates/projects/detail.html | 175 ++++++++++++++------ 6 files changed, 372 insertions(+), 169 deletions(-) create mode 100644 rebuild-prod.sh diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 8e31614..adbc81a 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -634,6 +634,30 @@ async def upload_nrl_data( if not file_entries: raise HTTPException(status_code=400, detail="No usable files found in upload.") + # --- Step 1b: Filter to only relevant files --- + # Keep: .rnh (metadata) and measurement .rnd files + # NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip) + # AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those + # Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else + def _is_wanted(fname: str) -> bool: + n = fname.lower() + if n.endswith(".rnh"): + return True + if n.endswith(".rnd"): + if "_leq_" in n: # NL-43 Leq file + return True + if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent + return True + if "_lp" not in n and "_leq_" not in n: + # Unknown .rnd format — include it so we don't silently drop data + return True + return False + + file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)] + + if not file_entries: + raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.") + # --- Step 2: Find and parse .rnh metadata --- rnh_meta = {} for fname, fbytes in file_entries: diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 2ef20eb..5a9002f 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -3379,12 +3379,17 @@ async def generate_combined_from_preview( data: dict, db: Session = Depends(get_db), ): - """Generate combined Excel report from wizard-edited spreadsheet data.""" + """Generate combined Excel report from wizard-edited spreadsheet data. + + Produces one .xlsx per day (each with one sheet per location) packaged + into a single .zip file for download. + """ try: import openpyxl from openpyxl.chart import LineChart, Reference from openpyxl.styles import Font, Alignment, Border, Side, PatternFill from openpyxl.utils import get_column_letter + from openpyxl.worksheet.properties import PageSetupProperties except ImportError: raise HTTPException(status_code=500, detail="openpyxl is not installed. Run: pip install openpyxl") @@ -3400,12 +3405,13 @@ async def generate_combined_from_preview( if not locations: raise HTTPException(status_code=400, detail="No location data provided") - # Styles + # Shared styles f_title = Font(name='Arial', bold=True, size=12) f_bold = Font(name='Arial', bold=True, size=10) f_data = Font(name='Arial', size=10) thin = Side(style='thin') dbl = Side(style='double') + med = Side(style='medium') hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) @@ -3415,34 +3421,28 @@ async def generate_combined_from_preview( data_inner = Border(left=thin, right=thin, top=thin, bottom=thin) data_left = Border(left=dbl, right=thin, top=thin, bottom=thin) data_right = Border(left=thin, right=dbl, top=thin, bottom=thin) - hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") - center_a = Alignment(horizontal='center', vertical='center', wrap_text=True) - left_a = Alignment(horizontal='left', vertical='center') - right_a = Alignment(horizontal='right', vertical='center') + hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + center_a = Alignment(horizontal='center', vertical='center', wrap_text=True) + left_a = Alignment(horizontal='left', vertical='center') + thin_border = Border(left=thin, right=thin, top=thin, bottom=thin) - from openpyxl.worksheet.properties import PageSetupProperties + tbl_top_left = Border(left=med, right=thin, top=med, bottom=thin) + tbl_top_mid = Border(left=thin, right=thin, top=med, bottom=thin) + tbl_top_right = Border(left=thin, right=med, top=med, bottom=thin) + tbl_mid_left = Border(left=med, right=thin, top=thin, bottom=thin) + tbl_mid_mid = Border(left=thin, right=thin, top=thin, bottom=thin) + tbl_mid_right = Border(left=thin, right=med, top=thin, bottom=thin) + tbl_bot_left = Border(left=med, right=thin, top=thin, bottom=med) + tbl_bot_mid = Border(left=thin, right=thin, top=thin, bottom=med) + tbl_bot_right = Border(left=thin, right=med, top=thin, bottom=med) - wb = openpyxl.Workbook() - wb.remove(wb.active) + col_widths = [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43] - all_location_summaries = [] - - for loc_info in locations: - loc_name = loc_info.get("location_name", "Unknown") - rows = loc_info.get("spreadsheet_data", []) - - if not rows: - continue - - safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31] - ws = wb.create_sheet(title=safe_sheet_name) - - # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2) - # A B C D E F G H I J K L M N O P - for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]): + def _build_location_sheet(ws, loc_name, day_rows, final_title): + """Write one location's data onto ws. day_rows is a list of spreadsheet row arrays.""" + for col_i, col_w in zip(range(1, 17), col_widths): ws.column_dimensions[get_column_letter(col_i)].width = col_w - final_title = f"{report_title} - {project_name}" ws.merge_cells('A1:G1') ws['A1'] = final_title ws['A1'].font = f_title; ws['A1'].alignment = center_a @@ -3463,16 +3463,13 @@ async def generate_combined_from_preview( cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) ws.row_dimensions[6].height = 39 - # Data rows starting at row 7 data_start_row = 7 - parsed_rows_p = [] - lmax_vals = [] - ln1_vals = [] - ln2_vals = [] + parsed_rows = [] + lmax_vals, ln1_vals, ln2_vals = [], [], [] - for row_idx, row in enumerate(rows): + for row_idx, row in enumerate(day_rows): dr = data_start_row + row_idx - is_last = (row_idx == len(rows) - 1) + is_last = (row_idx == len(day_rows) - 1) b_left = last_left if is_last else data_left b_inner = last_inner if is_last else data_inner b_right = last_right if is_last else data_right @@ -3508,20 +3505,18 @@ async def generate_combined_from_preview( if isinstance(ln2, (int, float)): ln2_vals.append(ln2) - # Parse time for evening/nighttime stats if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): try: try: row_dt = datetime.strptime(str(time_val), '%H:%M') except ValueError: row_dt = datetime.strptime(str(time_val), '%H:%M:%S') - parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2))) + parsed_rows.append((row_dt, float(lmax), float(ln1), float(ln2))) except (ValueError, TypeError): pass - data_end_row = data_start_row + len(rows) - 1 + data_end_row = data_start_row + len(day_rows) - 1 - # Chart anchored at H4 chart = LineChart() chart.title = f"{loc_name} - {final_title}" chart.style = 2 @@ -3545,7 +3540,6 @@ async def generate_combined_from_preview( ws.add_chart(chart, "H4") - # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35 note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") note1.font = f_data; note1.alignment = left_a ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14) @@ -3553,21 +3547,7 @@ async def generate_combined_from_preview( note2.font = f_data; note2.alignment = left_a ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) - # Table header row 31 - med = Side(style='medium') - tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin')) - tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin')) - tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin')) - tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) - tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) - tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin')) - tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med) - tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med) - tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med) - hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") - - # Header row: blank | Evening | Nighttime c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) @@ -3579,13 +3559,13 @@ async def generate_combined_from_preview( ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) ws.row_dimensions[31].height = 15 - evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22] - nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7] + evening = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if 19 <= dt.hour < 22] + nighttime = [(lmx, l1, l2) for dt, lmx, l1, l2 in parsed_rows if dt.hour >= 22 or dt.hour < 7] - def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None - def _max_p(vals): return round(max(vals), 1) if vals else None + def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None + def _max(vals): return round(max(vals), 1) if vals else None - def write_stat_p(row_num, label, eve_val, night_val, is_last=False): + def write_stat(row_num, label, eve_val, night_val, is_last=False): bl = tbl_bot_left if is_last else tbl_mid_left bm = tbl_bot_mid if is_last else tbl_mid_mid br = tbl_bot_right if is_last else tbl_mid_right @@ -3603,11 +3583,10 @@ async def generate_combined_from_preview( ni.alignment = Alignment(horizontal='center', vertical='center') ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) - write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p])) - write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p])) - write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True) + write_stat(32, "LAmax", _max([v[0] for v in evening]), _max([v[0] for v in nighttime])) + write_stat(33, "LA01 Average", _avg([v[1] for v in evening]), _avg([v[1] for v in nighttime])) + write_stat(34, "LA10 Average", _avg([v[2] for v in evening]), _avg([v[2] for v in nighttime]), is_last=True) - # Page setup: portrait, letter ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) ws.page_setup.orientation = 'portrait' ws.page_setup.paperSize = 1 @@ -3618,50 +3597,94 @@ async def generate_combined_from_preview( ws.page_margins.header = 0.5 ws.page_margins.footer = 0.5 - all_location_summaries.append({ + return { 'location': loc_name, - 'samples': len(rows), + 'samples': len(day_rows), 'lmax_avg': round(sum(lmax_vals) / len(lmax_vals), 1) if lmax_vals else None, - 'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None, - 'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None, - }) + 'ln1_avg': round(sum(ln1_vals) / len(ln1_vals), 1) if ln1_vals else None, + 'ln2_avg': round(sum(ln2_vals) / len(ln2_vals), 1) if ln2_vals else None, + } - # Summary sheet - thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), - top=Side(style='thin'), bottom=Side(style='thin')) - summary_ws = wb.create_sheet(title="Summary", index=0) - summary_ws['A1'] = f"{report_title} - {project_name} - Summary" - summary_ws['A1'].font = f_title - summary_ws.merge_cells('A1:E1') + def _build_summary_sheet(wb, day_label, project_name, loc_summaries): + summary_ws = wb.create_sheet(title="Summary", index=0) + summary_ws['A1'] = f"{report_title} - {project_name} - {day_label}" + summary_ws['A1'].font = f_title + summary_ws.merge_cells('A1:E1') + summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg'] + for col, header in enumerate(summary_headers, 1): + cell = summary_ws.cell(row=3, column=col, value=header) + cell.font = f_bold; cell.fill = hdr_fill; cell.border = thin_border + for i, width in enumerate([30, 10, 12, 12, 12], 1): + summary_ws.column_dimensions[get_column_letter(i)].width = width + for idx, s in enumerate(loc_summaries, 4): + summary_ws.cell(row=idx, column=1, value=s['location']).border = thin_border + summary_ws.cell(row=idx, column=2, value=s['samples']).border = thin_border + summary_ws.cell(row=idx, column=3, value=s['lmax_avg'] or '-').border = thin_border + summary_ws.cell(row=idx, column=4, value=s['ln1_avg'] or '-').border = thin_border + summary_ws.cell(row=idx, column=5, value=s['ln2_avg'] or '-').border = thin_border - summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg'] - for col, header in enumerate(summary_headers, 1): - cell = summary_ws.cell(row=3, column=col, value=header) - cell.font = f_bold - cell.fill = hdr_fill - cell.border = thin_border + # ---------------------------------------------------------------- + # Split each location's rows by date, collect all unique dates + # ---------------------------------------------------------------- + # Structure: dates_map[date_str][loc_name] = [row, ...] + dates_map: dict = {} + for loc_info in locations: + loc_name = loc_info.get("location_name", "Unknown") + rows = loc_info.get("spreadsheet_data", []) + for row in rows: + date_val = str(row[1]).strip() if len(row) > 1 else '' + if not date_val: + date_val = "Unknown Date" + dates_map.setdefault(date_val, {}).setdefault(loc_name, []).append(row) - for i, width in enumerate([30, 10, 12, 12, 12], 1): - summary_ws.column_dimensions[get_column_letter(i)].width = width + if not dates_map: + raise HTTPException(status_code=400, detail="No data rows found in provided location data") - for idx, loc_summary in enumerate(all_location_summaries, 4): - summary_ws.cell(row=idx, column=1, value=loc_summary['location']).border = thin_border - summary_ws.cell(row=idx, column=2, value=loc_summary['samples']).border = thin_border - summary_ws.cell(row=idx, column=3, value=loc_summary['lmax_avg'] or '-').border = thin_border - summary_ws.cell(row=idx, column=4, value=loc_summary['ln1_avg'] or '-').border = thin_border - summary_ws.cell(row=idx, column=5, value=loc_summary['ln2_avg'] or '-').border = thin_border + sorted_dates = sorted(dates_map.keys()) + project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip().replace(' ', '_') - output = io.BytesIO() - wb.save(output) - output.seek(0) + # ---------------------------------------------------------------- + # Build one workbook per day, zip them + # ---------------------------------------------------------------- + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for date_str in sorted_dates: + loc_data_for_day = dates_map[date_str] + final_title = f"{report_title} - {project_name}" - project_name_clean = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')).strip() - filename = f"{project_name_clean}_combined_report.xlsx".replace(' ', '_') + wb = openpyxl.Workbook() + wb.remove(wb.active) + + loc_summaries = [] + for loc_name in sorted(loc_data_for_day.keys()): + day_rows = loc_data_for_day[loc_name] + # Re-number interval # sequentially for this day + for i, row in enumerate(day_rows): + if len(row) > 0: + row[0] = i + 1 + + safe_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31] + ws = wb.create_sheet(title=safe_name) + summary = _build_location_sheet(ws, loc_name, day_rows, final_title) + loc_summaries.append(summary) + + _build_summary_sheet(wb, date_str, project_name, loc_summaries) + + xlsx_buf = io.BytesIO() + wb.save(xlsx_buf) + xlsx_buf.seek(0) + + date_clean = date_str.replace('/', '-').replace(' ', '_') + xlsx_name = f"{project_name_clean}_{date_clean}_report.xlsx" + zf.writestr(xlsx_name, xlsx_buf.read()) + + zip_buffer.seek(0) + zip_filename = f"{project_name_clean}_reports.zip" return StreamingResponse( - output, - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": f'attachment; filename="{filename}"'} + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'} ) diff --git a/rebuild-prod.sh b/rebuild-prod.sh new file mode 100644 index 0000000..8226269 --- /dev/null +++ b/rebuild-prod.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Production rebuild script — rebuilds and restarts terra-view on :8001 +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Building terra-view production..." +docker compose -f docker-compose.yml build terra-view +docker compose -f docker-compose.yml up -d terra-view + +echo "Done — terra-view production is running on :8001" diff --git a/templates/combined_report_preview.html b/templates/combined_report_preview.html index e7078f4..1439e33 100644 --- a/templates/combined_report_preview.html +++ b/templates/combined_report_preview.html @@ -26,7 +26,7 @@ - Generate Excel + Generate Reports (ZIP) @@ -238,7 +238,7 @@ async function downloadCombinedReport() { const btn = document.getElementById('download-btn'); const originalText = btn.innerHTML; btn.disabled = true; - btn.innerHTML = ' Generating...'; + btn.innerHTML = ' Generating ZIP...'; try { const locations = allLocationData.map(function(loc) { @@ -268,7 +268,7 @@ async function downloadCombinedReport() { a.href = url; const contentDisposition = response.headers.get('Content-Disposition'); - let filename = 'combined_report.xlsx'; + let filename = 'combined_reports.zip'; if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match) filename = match[1]; diff --git a/templates/nrl_detail.html b/templates/nrl_detail.html index edebe16..cafc12a 100644 --- a/templates/nrl_detail.html +++ b/templates/nrl_detail.html @@ -385,16 +385,27 @@ file:text-sm file:font-medium file:bg-seismo-orange file:text-white hover:file:bg-seismo-navy file:cursor-pointer" />
- -
+ +
{ + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + progressBar.style.width = pct + '%'; + progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`; + } + }); + + xhr.upload.addEventListener('load', () => { + progressBar.style.width = '100%'; + progressLabel.textContent = 'Processing files on server\u2026'; + }); + + xhr.addEventListener('load', () => { + progressWrap.classList.add('hidden'); + btn.disabled = false; + btn.textContent = 'Import Files'; + btn.classList.remove('opacity-60', 'cursor-not-allowed'); + cancelBtn.disabled = false; + cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed'); + + try { + const data = JSON.parse(xhr.responseText); + if (xhr.status >= 200 && xhr.status < 300) { + const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`]; + if (data.leq_files || data.lp_files) { + parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`); + } + if (data.store_name) parts.push(`\u2014 ${data.store_name}`); + status.textContent = parts.join(' '); + status.className = 'text-sm text-green-600 dark:text-green-400'; + input.value = ''; + htmx.trigger(document.getElementById('data-files-list'), 'load'); + } else { + status.textContent = `Error: ${data.detail || 'Upload failed'}`; + status.className = 'text-sm text-red-600 dark:text-red-400'; } - if (data.store_name) parts.push(`\u2014 ${data.store_name}`); - status.textContent = parts.join(' '); - status.className = 'text-sm text-green-600 dark:text-green-400'; - input.value = ''; - // Refresh the file list - htmx.trigger(document.getElementById('data-files-list'), 'load'); - } else { - status.textContent = `Error: ${data.detail || 'Upload failed'}`; + } catch { + status.textContent = 'Error: Unexpected server response'; status.className = 'text-sm text-red-600 dark:text-red-400'; } - } catch (err) { - status.textContent = `Error: ${err.message}`; + }); + + xhr.addEventListener('error', () => { + progressWrap.classList.add('hidden'); + btn.disabled = false; + btn.textContent = 'Import Files'; + btn.classList.remove('opacity-60', 'cursor-not-allowed'); + cancelBtn.disabled = false; + cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed'); + status.textContent = 'Error: Network error during upload'; status.className = 'text-sm text-red-600 dark:text-red-400'; - } + }); + + xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`); + xhr.send(formData); } {% endblock %} diff --git a/templates/projects/detail.html b/templates/projects/detail.html index 40110ad..784cd55 100644 --- a/templates/projects/detail.html +++ b/templates/projects/detail.html @@ -264,16 +264,28 @@ file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-seismo-orange file:text-white hover:file:bg-seismo-navy file:cursor-pointer" /> - -
+ + @@ -1642,75 +1654,148 @@ function toggleUploadAll() { document.getElementById('upload-all-results').classList.add('hidden'); document.getElementById('upload-all-results').innerHTML = ''; document.getElementById('upload-all-input').value = ''; + document.getElementById('upload-all-file-count').classList.add('hidden'); + document.getElementById('upload-all-progress-wrap').classList.add('hidden'); + document.getElementById('upload-all-progress-bar').style.width = '0%'; } } -async function submitUploadAll() { +// Show file count and filter info when folder is selected +document.getElementById('upload-all-input').addEventListener('change', function() { + const countEl = document.getElementById('upload-all-file-count'); + const total = this.files.length; + if (!total) { countEl.classList.add('hidden'); return; } + const wanted = Array.from(this.files).filter(_isWantedFile).length; + countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`; + countEl.classList.remove('hidden'); +}); + +function _isWantedFile(f) { + const n = (f.webkitRelativePath || f.name).toLowerCase(); + const base = n.split('/').pop(); + if (base.endsWith('.rnh')) return true; + if (base.endsWith('.rnd')) { + if (base.includes('_leq_')) return true; // NL-43 Leq + if (base.startsWith('au2_')) return true; // AU2/NL-23 format + if (!base.includes('_lp')) return true; // unknown format — keep + } + return false; +} + +function submitUploadAll() { const input = document.getElementById('upload-all-input'); const status = document.getElementById('upload-all-status'); const resultsEl = document.getElementById('upload-all-results'); + const btn = document.getElementById('upload-all-btn'); + const cancelBtn = document.getElementById('upload-all-cancel-btn'); + const progressWrap = document.getElementById('upload-all-progress-wrap'); + const progressBar = document.getElementById('upload-all-progress-bar'); + const progressLabel = document.getElementById('upload-all-progress-label'); if (!input.files.length) { alert('Please select a folder to upload.'); return; } + // Filter client-side — only send Leq .rnd and .rnh files + const filesToSend = Array.from(input.files).filter(_isWantedFile); + if (!filesToSend.length) { + alert('No Leq .rnd or .rnh files found in selected folder.'); + return; + } + const formData = new FormData(); - for (const f of input.files) { - // webkitRelativePath gives the path relative to the selected folder root + for (const f of filesToSend) { formData.append('files', f); formData.append('paths', f.webkitRelativePath || f.name); } - status.textContent = `Uploading ${input.files.length} files\u2026`; - status.className = 'text-sm text-gray-500'; + // Disable controls and show progress + btn.disabled = true; + btn.textContent = 'Uploading\u2026'; + btn.classList.add('opacity-60', 'cursor-not-allowed'); + cancelBtn.disabled = true; + cancelBtn.classList.add('opacity-40', 'cursor-not-allowed'); + status.className = 'text-sm hidden'; resultsEl.classList.add('hidden'); + progressWrap.classList.remove('hidden'); + progressBar.style.width = '0%'; + progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`; - try { - const response = await fetch( - `/api/projects/{{ project_id }}/upload-all`, - { method: 'POST', body: formData } - ); - const data = await response.json(); + const xhr = new XMLHttpRequest(); - if (response.ok) { - const s = data.sessions_created; - const f = data.files_imported; - status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`; - status.className = 'text-sm text-green-600 dark:text-green-400'; - input.value = ''; + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + progressBar.style.width = pct + '%'; + progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`; + } + }); - // Build results summary - let html = ''; - if (data.sessions && data.sessions.length) { - html += '
Sessions created:
'; - html += ''; + if (data.unmatched_folders && data.unmatched_folders.length) { + html += `
\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}
`; + } + if (html) { + resultsEl.innerHTML = html; + resultsEl.classList.remove('hidden'); + } + htmx.trigger(document.getElementById('unified-files'), 'refresh'); + } else { + status.textContent = `Error: ${data.detail || 'Upload failed'}`; + status.className = 'text-sm text-red-600 dark:text-red-400'; } - if (data.unmatched_folders && data.unmatched_folders.length) { - html += `
\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}
`; - } - if (html) { - resultsEl.innerHTML = html; - resultsEl.classList.remove('hidden'); - } - - // Refresh the unified files view - htmx.trigger(document.getElementById('unified-files'), 'refresh'); - } else { - status.textContent = `Error: ${data.detail || 'Upload failed'}`; + } catch { + status.textContent = 'Error: Unexpected server response'; status.className = 'text-sm text-red-600 dark:text-red-400'; } - } catch (err) { - status.textContent = `Error: ${err.message}`; + }); + + xhr.addEventListener('error', () => { + _resetControls(); + status.textContent = 'Error: Network error during upload'; status.className = 'text-sm text-red-600 dark:text-red-400'; - } + }); + + xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`); + xhr.send(formData); } // Load project details on page load and restore active tab from URL hash