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.
This commit is contained in:
2026-03-06 23:37:24 +00:00
parent 14856e61ef
commit 67a2faa2d3
6 changed files with 372 additions and 169 deletions

View File

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

View File

@@ -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}"'}
)

12
rebuild-prod.sh Normal file
View File

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

View File

@@ -26,7 +26,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Generate Excel
Generate Reports (ZIP)
</button>
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
@@ -238,7 +238,7 @@ async function downloadCombinedReport() {
const btn = document.getElementById('download-btn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating...';
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 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];

View File

@@ -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" />
<div class="flex items-center gap-3 mt-3">
<button onclick="submitUpload()"
<button id="upload-btn" onclick="submitUpload()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import Files
</button>
<button onclick="toggleUploadPanel()"
<button id="upload-cancel-btn" onclick="toggleUploadPanel()"
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Cancel
</button>
<span id="upload-status" class="text-sm hidden"></span>
</div>
<!-- Progress bar (hidden until upload starts) -->
<div id="upload-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
</div>
<div id="data-files-list"
@@ -629,57 +640,105 @@ function toggleUploadPanel() {
const panel = document.getElementById('upload-panel');
const status = document.getElementById('upload-status');
panel.classList.toggle('hidden');
// Reset status when reopening
// Reset state when reopening
if (!panel.classList.contains('hidden')) {
status.textContent = '';
status.className = 'text-sm hidden';
document.getElementById('upload-input').value = '';
document.getElementById('upload-progress-wrap').classList.add('hidden');
document.getElementById('upload-progress-bar').style.width = '0%';
}
}
async function submitUpload() {
function submitUpload() {
const input = document.getElementById('upload-input');
const status = document.getElementById('upload-status');
const btn = document.getElementById('upload-btn');
const cancelBtn = document.getElementById('upload-cancel-btn');
const progressWrap = document.getElementById('upload-progress-wrap');
const progressBar = document.getElementById('upload-progress-bar');
const progressLabel = document.getElementById('upload-progress-label');
if (!input.files.length) {
alert('Please select files to upload.');
return;
}
const fileCount = input.files.length;
const formData = new FormData();
for (const file of input.files) {
formData.append('files', file);
}
status.textContent = 'Uploading\u2026';
status.className = 'text-sm text-gray-500';
// Disable controls and show progress bar
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';
progressWrap.classList.remove('hidden');
progressBar.style.width = '0%';
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
try {
const response = await fetch(
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
{ method: 'POST', body: formData }
);
const data = await response.json();
const xhr = new XMLHttpRequest();
if (response.ok) {
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)`);
xhr.upload.addEventListener('progress', (e) => {
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);
}
</script>
{% endblock %}

View File

@@ -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" />
<button onclick="submitUploadAll()"
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
<button id="upload-all-btn" onclick="submitUploadAll()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import
</button>
<button onclick="toggleUploadAll()"
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
Cancel
</button>
<span id="upload-all-status" class="text-sm hidden"></span>
</div>
<!-- Progress bar -->
<div id="upload-all-progress-wrap" class="hidden mt-3">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span id="upload-all-progress-label">Uploading…</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="upload-all-progress-bar"
class="bg-green-500 h-2 rounded-full transition-all duration-300"
style="width: 0%"></div>
</div>
</div>
<!-- Result summary -->
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div>
@@ -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 += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
xhr.upload.addEventListener('load', () => {
progressBar.style.width = '100%';
progressLabel.textContent = 'Processing files on server\u2026';
});
function _resetControls() {
progressWrap.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import';
btn.classList.remove('opacity-60', 'cursor-not-allowed');
cancelBtn.disabled = false;
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
}
xhr.addEventListener('load', () => {
_resetControls();
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
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 = '';
document.getElementById('upload-all-file-count').classList.add('hidden');
let html = '';
if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
html += '</ul>';
}
html += '</ul>';
if (data.unmatched_folders && data.unmatched_folders.length) {
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
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 += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
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