Add scan preview/apply for call history

Adds a workflow to scan uploaded call history files for matching QSOs and apply SIG/SIG_INFO backfills. Controller Callhistory: loads logbooks, adds scan_preview and scan_apply actions, CSV parsing helpers, and safety checks to ensure files are readable and station ownership matches. Model Callhistory_model: adds get_station_ids_for_logbook, get_qsos_for_callsigns, and apply_sig_backfill to fetch candidate QSOs within a user's station/logbook scope and apply updates in a DB transaction. View callhistory/index.php: introduces a preview UI with selection controls, logbook scope selector, and client-side JS to manage selections and submission. Changes include normalization/heuristics for extracting callsigns/exchanges and only propose updates where existing SIG fields are blank.
This commit is contained in:
Peter Goodhall 2026-03-24 23:22:21 +00:00
parent a049d33b3a
commit ed0578cd18
3 changed files with 480 additions and 0 deletions

View file

@ -21,8 +21,10 @@ class Callhistory extends CI_Controller {
{
$user_id = (int)$this->session->userdata('user_id');
$this->load->model('logbooks_model');
$data['page_title'] = 'Call History';
$data['files'] = $this->callhistory_model->get_all_for_user($user_id);
$data['logbooks'] = $this->logbooks_model->show_all()->result();
$this->load->view('interface_assets/header', $data);
$this->load->view('callhistory/index', $data);
@ -167,6 +169,147 @@ class Callhistory extends CI_Controller {
redirect('callhistory');
}
public function scan_preview()
{
if (strtolower($this->input->method()) !== 'post') {
redirect('callhistory');
return;
}
$user_id = (int)$this->session->userdata('user_id');
$file_id = (int)$this->input->post('file_id', TRUE);
$logbook_id = $this->input->post('logbook_id', TRUE);
$logbook_id = ($logbook_id !== '' && $logbook_id !== FALSE) ? (int)$logbook_id : NULL;
$file = $this->callhistory_model->get_for_user_by_id($user_id, $file_id);
if (!$file) {
$this->session->set_flashdata('notice', 'Call history file not found.');
redirect('callhistory');
return;
}
$path = FCPATH . 'uploads/callhistory/' . $user_id . '/' . $file->stored_filename;
if (!is_file($path) || !is_readable($path)) {
$this->session->set_flashdata('notice', 'Uploaded file is not readable.');
redirect('callhistory');
return;
}
// Parse every callsign in the file
$all_callsigns = $this->get_all_callsigns_in_file($path);
if (empty($all_callsigns)) {
$this->session->set_flashdata('notice', 'No callsigns found in the selected file.');
redirect('callhistory');
return;
}
// Look up matching QSOs for all those callsigns
$qsos = $this->callhistory_model->get_qsos_for_callsigns($user_id, array_keys($all_callsigns), $logbook_id);
$preview = array();
foreach ($qsos as $qso) {
$normalized_call = $this->normalize_callsign($qso->COL_CALL);
if (!isset($all_callsigns[$normalized_call])) {
continue;
}
$call_data = $all_callsigns[$normalized_call];
$proposed_sig = $file->organization_label;
$proposed_sig_info = $call_data['exch1'];
$current_sig = trim((string)($qso->COL_SIG ?? ''));
$current_sig_info = trim((string)($qso->COL_SIG_INFO ?? ''));
// Only propose changes where both SIG fields are currently blank
if ($current_sig !== '' || $current_sig_info !== '') {
continue;
}
if ($proposed_sig === '' && $proposed_sig_info === '') {
continue;
}
$preview[] = array(
'qso_id' => (int)$qso->COL_PRIMARY_KEY,
'station_id' => (int)$qso->station_id,
'callsign' => $qso->COL_CALL,
'time_on' => $qso->COL_TIME_ON,
'band' => $qso->COL_BAND,
'mode' => $qso->COL_SUBMODE !== '' ? $qso->COL_SUBMODE : $qso->COL_MODE,
'station_location' => $qso->station_profile_name . ' (' . $qso->station_callsign . ')',
'current_sig' => $current_sig,
'current_sig_info' => $current_sig_info,
'new_sig' => $proposed_sig,
'new_sig_info' => $proposed_sig_info,
);
}
$this->load->model('logbooks_model');
$data['page_title'] = 'Call History - Scan Preview';
$data['files'] = $this->callhistory_model->get_all_for_user($user_id);
$data['preview'] = $preview;
$data['scan_file'] = $file;
$data['logbooks'] = $this->logbooks_model->show_all()->result();
$data['selected_logbook_id'] = $logbook_id;
$this->load->view('interface_assets/header', $data);
$this->load->view('callhistory/index', $data);
$this->load->view('interface_assets/footer');
}
public function scan_apply()
{
if (strtolower($this->input->method()) !== 'post') {
redirect('callhistory');
return;
}
$user_id = (int)$this->session->userdata('user_id');
$raw_changes = $this->input->post('changes', TRUE);
if (empty($raw_changes) || !is_array($raw_changes)) {
$this->session->set_flashdata('notice', 'No changes submitted.');
redirect('callhistory');
return;
}
// Validate that every station_id belongs to this user before applying
$station_ids = $this->callhistory_model->get_station_ids_for_logbook($user_id);
$station_ids_set = array_flip($station_ids);
$safe_changes = array();
foreach ($raw_changes as $change) {
$qso_id = isset($change['qso_id']) ? (int)$change['qso_id'] : 0;
$station_id = isset($change['station_id']) ? (int)$change['station_id'] : 0;
if ($qso_id <= 0 || $station_id <= 0) {
continue;
}
if (!isset($station_ids_set[$station_id])) {
continue;
}
$safe_changes[] = array(
'qso_id' => $qso_id,
'station_id' => $station_id,
'new_sig' => $this->security->xss_clean((string)($change['new_sig'] ?? '')),
'new_sig_info' => $this->security->xss_clean((string)($change['new_sig_info'] ?? '')),
);
}
if (empty($safe_changes)) {
$this->session->set_flashdata('notice', 'No valid changes to apply.');
redirect('callhistory');
return;
}
$applied = $this->callhistory_model->apply_sig_backfill($safe_changes);
$this->session->set_flashdata('notice', $applied . ' QSO(s) updated with SIG data.');
redirect('callhistory');
}
public function lookup()
{
if (strtolower($this->input->method()) !== 'post') {
@ -213,6 +356,75 @@ class Callhistory extends CI_Controller {
echo json_encode($response);
}
private function get_all_callsigns_in_file($file_path)
{
$callsigns = array();
$header_map = null;
if (($handle = fopen($file_path, 'r')) === FALSE) {
return $callsigns;
}
while (($row = fgetcsv($handle, 0, ',')) !== FALSE) {
if (empty($row)) {
continue;
}
$row = array_map('trim', $row);
if (count($row) === 1 && $row[0] === '') {
continue;
}
if ($this->is_comment_row($row)) {
continue;
}
if ($this->looks_like_header($row)) {
$header_map = $this->build_header_map($row);
continue;
}
$row_callsign = $this->extract_by_map_or_index($row, $header_map, array('call', 'callsign', 'callsigns'), 0);
$row_callsign = $this->normalize_callsign($row_callsign);
if ($row_callsign === '') {
continue;
}
// Strip portable/alternative suffixes to get base call for matching
$base_callsign = $row_callsign;
if (strpos($base_callsign, '/') !== FALSE) {
$parts = explode('/', $base_callsign);
// Use the longest segment as the base callsign
usort($parts, function($a, $b) { return strlen($b) - strlen($a); });
$base_callsign = $parts[0];
}
$name = $this->extract_name($row, $header_map);
$exch1 = $this->extract_by_map_or_index($row, $header_map, array('exch1', 'exchange1', 'exchange', 'member', 'membership'), 8);
if ($exch1 === '' && is_null($header_map)) {
$exch1 = $this->guess_exch1_without_header($row);
}
$callsigns[$base_callsign] = array(
'name' => $name,
'exch1' => $exch1,
);
// Also index by original callsign if it differs (e.g. contains /)
if ($row_callsign !== $base_callsign) {
$callsigns[$row_callsign] = array(
'name' => $name,
'exch1' => $exch1,
);
}
}
fclose($handle);
return $callsigns;
}
private function find_matches_in_file($file_path, $callsign, $file)
{
$matches = array();

View file

@ -57,4 +57,115 @@ class Callhistory_model extends CI_Model {
$this->db->where('id', (int)$id);
return $this->db->delete('callhistory_files');
}
public function get_station_ids_for_logbook($user_id, $logbook_id = NULL)
{
if ($logbook_id === NULL || $logbook_id === '' || $logbook_id === 0 || $logbook_id === '0') {
$this->db->select('station_id');
$this->db->from('station_profile');
$this->db->where('user_id', (int)$user_id);
$query = $this->db->get();
$station_ids = array();
foreach ($query->result() as $row) {
$station_ids[] = (int)$row->station_id;
}
return $station_ids;
}
$this->db->select('station_location_id');
$this->db->from('station_logbooks_relationship');
$this->db->where('station_logbook_id', (int)$logbook_id);
$query = $this->db->get();
$candidate_ids = array();
foreach ($query->result() as $row) {
$candidate_ids[] = (int)$row->station_location_id;
}
if (empty($candidate_ids)) {
return array();
}
$this->db->select('station_id');
$this->db->from('station_profile');
$this->db->where('user_id', (int)$user_id);
$this->db->where_in('station_id', $candidate_ids);
$verified_query = $this->db->get();
$verified_ids = array();
foreach ($verified_query->result() as $row) {
$verified_ids[] = (int)$row->station_id;
}
return $verified_ids;
}
public function get_qsos_for_callsigns($user_id, $callsigns, $logbook_id = NULL)
{
$callsigns = array_values(array_unique(array_filter(array_map('strval', (array)$callsigns))));
if (empty($callsigns)) {
return array();
}
$station_ids = $this->get_station_ids_for_logbook($user_id, $logbook_id);
if (empty($station_ids)) {
return array();
}
$table = $this->config->item('table_name');
$results = array();
foreach (array_chunk($callsigns, 500) as $callsign_chunk) {
$escaped_callsigns = array();
foreach ($callsign_chunk as $callsign) {
$escaped_callsigns[] = $this->db->escape(strtoupper($callsign));
}
$this->db->select($table . '.COL_PRIMARY_KEY, ' . $table . '.COL_CALL, ' . $table . '.COL_TIME_ON, ' . $table . '.COL_BAND, ' . $table . '.COL_MODE, ' . $table . '.COL_SUBMODE, ' . $table . '.COL_SIG, ' . $table . '.COL_SIG_INFO, ' . $table . '.station_id, station_profile.station_profile_name, station_profile.station_callsign');
$this->db->from($table);
$this->db->join('station_profile', 'station_profile.station_id = ' . $table . '.station_id');
$this->db->where('station_profile.user_id', (int)$user_id);
$this->db->where_in($table . '.station_id', $station_ids);
$this->db->where('UPPER(REPLACE(' . $table . '.COL_CALL, "Ø", "0")) IN (' . implode(',', $escaped_callsigns) . ')', NULL, FALSE);
$this->db->order_by($table . '.COL_TIME_ON', 'DESC');
$query = $this->db->get();
$results = array_merge($results, $query->result());
}
return $results;
}
public function apply_sig_backfill($changes)
{
if (empty($changes)) {
return 0;
}
$table = $this->config->item('table_name');
$applied = 0;
$this->db->trans_start();
foreach ($changes as $change) {
$this->db->where('COL_PRIMARY_KEY', (int)$change['qso_id']);
$this->db->where('station_id', (int)$change['station_id']);
$this->db->update($table, array(
'COL_SIG' => $change['new_sig'],
'COL_SIG_INFO' => $change['new_sig_info'],
));
if ($this->db->affected_rows() >= 0) {
$applied++;
}
}
$this->db->trans_complete();
if (!$this->db->trans_status()) {
return 0;
}
return $applied;
}
}

View file

@ -2,6 +2,100 @@
<h2>Call History</h2>
<p class="text-muted">Upload N1MM call history files (.txt/.csv), assign an optional organization label (e.g., FOC), and set file priority for matching.</p>
<?php if (!empty($preview)) { ?>
<div class="card mb-3 border-warning">
<div class="card-header bg-warning text-dark">
<strong><i class="fas fa-search"></i> Scan Preview &mdash; <?php echo htmlspecialchars($scan_file->original_filename); ?></strong>
<span class="badge bg-dark ms-2"><?php echo count($preview); ?> QSO(s) with blank SIG fields found</span>
</div>
<div class="card-body">
<p class="text-muted small mb-2">Only QSOs with <strong>no existing SIG data</strong> are shown. Review the proposed values below, then click <strong>Apply Selected</strong> to write them.</p>
<form method="post" action="<?php echo site_url('callhistory/scan_apply'); ?>" id="apply-form">
<div class="mb-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="select-all-btn"><i class="fas fa-check-double"></i> Select All</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="deselect-all-btn"><i class="fas fa-times"></i> Deselect All</button>
<button type="submit" class="btn btn-sm btn-success" id="apply-btn"><i class="fas fa-save"></i> Apply Selected (<span id="selected-count"><?php echo count($preview); ?></span>)</button>
</div>
<div class="table-responsive">
<table id="callhistory-preview-table" class="table table-sm table-striped table-hover w-100">
<thead>
<tr>
<th style="width:30px;"><input type="checkbox" id="check-all" checked></th>
<th>Callsign</th>
<th>Date/Time</th>
<th>Band</th>
<th>Mode</th>
<th>Station Location</th>
<th>SIG &rarr;</th>
<th>SIG Info &rarr;</th>
</tr>
</thead>
<tbody>
<?php foreach ($preview as $i => $row) { ?>
<tr>
<td>
<input type="checkbox" class="row-check" name="changes[<?php echo $i; ?>][qso_id]"
value="<?php echo (int)$row['qso_id']; ?>" checked>
<input type="hidden" name="changes[<?php echo $i; ?>][station_id]" value="<?php echo (int)$row['station_id']; ?>">
<input type="hidden" name="changes[<?php echo $i; ?>][new_sig]" value="<?php echo htmlspecialchars($row['new_sig']); ?>">
<input type="hidden" name="changes[<?php echo $i; ?>][new_sig_info]" value="<?php echo htmlspecialchars($row['new_sig_info']); ?>">
</td>
<td><?php echo htmlspecialchars($row['callsign']); ?></td>
<td><?php echo htmlspecialchars($row['time_on']); ?></td>
<td><?php echo htmlspecialchars($row['band']); ?></td>
<td><?php echo htmlspecialchars($row['mode']); ?></td>
<td><?php echo htmlspecialchars($row['station_location']); ?></td>
<td><span class="badge bg-primary"><?php echo htmlspecialchars($row['new_sig']); ?></span></td>
<td><span class="badge bg-secondary"><?php echo htmlspecialchars($row['new_sig_info']); ?></span></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</form>
</div>
</div>
<?php } elseif (isset($scan_file)) { ?>
<div class="alert alert-info"><i class="fas fa-info-circle"></i> No blank-SIG matches found for <strong><?php echo htmlspecialchars($scan_file->original_filename); ?></strong> in the selected logbook scope.</div>
<?php } ?>
<?php if (!empty($files)) { ?>
<div class="card mb-3">
<div class="card-header">
<strong><i class="fas fa-search-plus"></i> Scan Logbook</strong>
</div>
<div class="card-body">
<form method="post" action="<?php echo site_url('callhistory/scan_preview'); ?>" class="row g-3 align-items-end">
<div class="col-md-4">
<label for="scan_file_id" class="form-label">Call History File</label>
<select class="form-select" id="scan_file_id" name="file_id" required>
<?php foreach ($files as $f) { ?>
<option value="<?php echo (int)$f->id; ?>" <?php echo (isset($scan_file) && (int)$scan_file->id === (int)$f->id) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($f->file_label ?: $f->original_filename); ?>
<?php if (!empty($f->organization_label)) { echo '(' . htmlspecialchars($f->organization_label) . ')'; } ?>
</option>
<?php } ?>
</select>
</div>
<div class="col-md-4">
<label for="scan_logbook_id" class="form-label">Station Location Scope</label>
<select class="form-select" id="scan_logbook_id" name="logbook_id">
<option value="">All my station locations</option>
<?php if (!empty($logbooks)) { foreach ($logbooks as $lb) { ?>
<option value="<?php echo (int)$lb->logbook_id; ?>" <?php echo (isset($selected_logbook_id) && (int)$selected_logbook_id === (int)$lb->logbook_id) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($lb->logbook_name); ?>
</option>
<?php } } ?>
</select>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-warning"><i class="fas fa-search"></i> Preview Changes</button>
</div>
</form>
</div>
</div>
<?php } ?>
<div class="card mb-3">
<div class="card-header">
<strong>Upload Call History File</strong>
@ -99,3 +193,66 @@
</div>
</div>
</div>
<?php if (!empty($preview)) { ?>
<script>
$(document).ready(function () {
var table = $('#callhistory-preview-table').DataTable({
"pageLength": 25,
"order": [[2, "asc"]],
"columnDefs": [
{ "orderable": false, "targets": 0 },
{ "orderable": false, "targets": 6 },
{ "orderable": false, "targets": 7 }
],
"language": {
url: getDataTablesLanguageUrl()
}
});
function updateSelectedCount() {
var count = $('#callhistory-preview-table tbody .row-check:checked').length;
$('#selected-count').text(count);
}
$('#check-all').on('change', function () {
var checked = $(this).prop('checked');
$('#callhistory-preview-table tbody .row-check').prop('checked', checked);
updateSelectedCount();
});
$('#select-all-btn').on('click', function () {
$('#callhistory-preview-table tbody .row-check').prop('checked', true);
$('#check-all').prop('checked', true);
updateSelectedCount();
});
$('#deselect-all-btn').on('click', function () {
$('#callhistory-preview-table tbody .row-check').prop('checked', false);
$('#check-all').prop('checked', false);
updateSelectedCount();
});
$('#callhistory-preview-table tbody').on('change', '.row-check', function () {
updateSelectedCount();
});
$('#apply-form').on('submit', function (e) {
// Disable checkboxes that are unchecked so their hidden inputs are not submitted
$('#callhistory-preview-table tbody .row-check:not(:checked)').each(function () {
var idx = $(this).attr('name').replace('[qso_id]', '');
$('[name="' + idx + '[station_id]"],' +
'[name="' + idx + '[new_sig]"],' +
'[name="' + idx + '[new_sig_info]"]').prop('disabled', true);
$(this).prop('disabled', true);
});
var count = $('#apply-form input.row-check:not(:disabled)').length;
if (count === 0) {
e.preventDefault();
alert('No rows selected.');
}
});
});
</script>
<?php } ?>