user_model->authorize(2)) {
$this->session->set_flashdata('error', __("You're not allowed to do that!"));
redirect('dashboard');
}
if ($this->session->userdata('station_profile_id') ?? 0) { // Last Station from session accessible? Take it!
$this->active_station_location = $this->session->userdata('station_profile_id');
} else {
$this->active_station_location = $this->stations->find_active();
}
$this->load->is_loaded('Worker') ?: $this->load->library('Worker');
$this->worker_available = $this->worker->is_enabled();
$this->load->driver('cache', [
'adapter' => $this->config->item('cache_adapter') ?? 'file',
'backup' => $this->config->item('cache_backup') ?? 'file',
'key_prefix' => $this->config->item('cache_key_prefix') ?? ''
]);
}
/**
* Contest Management Dashboard
*/
public function index() {
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
$this->load->is_loaded('form_validation') ?: $this->load->library('form_validation');
$data['page_title'] = __("Contest Management");
// is a custom name set, we use this one and the ADIF name in brackets, otherwise we just use the ADIF name
$data['my_contests'] = array_map(function($row) {
$row['display_name'] = !empty($row['custom_name'])
? htmlspecialchars($row['custom_name']) . ' (' . htmlspecialchars($row['contestname']) . ')'
: htmlspecialchars($row['contestname'] ?? '-');
return $row;
}, $this->contesting_model->get_user_contests());
if ($this->session->userdata('user_date_format')) {
$data['custom_date_format'] = $this->session->userdata('user_date_format');
} else {
$data['custom_date_format'] = $this->config->item('qso_date_format');
}
$footerData = [];
$footerData['scripts'] = [
'assets/js/sections/contesting/manager.js',
];
$this->load->view('interface_assets/header', $data);
$this->load->view('contesting/manager/index');
$this->load->view('interface_assets/footer', $footerData);
}
public function quickstart() {
if (!clubaccess_check(9)) {
$this->session->set_flashdata('error', __("Officers must set up contests."));
redirect('contesting');
}
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
$this->load->is_loaded('stations') ?: $this->load->model('stations');
if ($this->session->userdata('user_date_format')) {
$custom_date_format = $this->session->userdata('user_date_format');
} else {
$custom_date_format = $this->config->item('qso_date_format');
}
$contest_adif_id = 1; // Contest "Other"
$session_start = date('Y-m-d H:i');
$session_end = date('Y-m-d H:i', strtotime('+48 hours')); // 48 hours from now
$station_location = $this->active_station_location;
$session_notes = sprintf(__("Quickstart Session: %s"), date($custom_date_format . ' H:i'));
$session_id = $this->contesting_model->create_contest_session($contest_adif_id, $session_start, $session_end, $station_location, $session_notes, true);
redirect('contesting/logging_engine/' . $session_id);
}
/**
* CBR Export Page for contests
*/
public function export_cbr($contest_session_id) {
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($contest_session_id)) {
$this->session->set_flashdata('error', __("Contest session not found."));
redirect('contesting');
}
$session_info = $this->contesting_model->get_session_info($contest_session_id);
$cabrillo = $this->contesting_model->get_exportformat_settings($contest_session_id, "cabrillo");
$userinfo = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();
$session_operators = $this->contesting_model->get_session_operators($contest_session_id);
$effective_name = !empty($session_info['custom_name']) ? $session_info['custom_name'] : $session_info['contest_name'];
$data['page_title'] = sprintf(__("CBR Export: %s"), $effective_name) . ($session_info['comment'] != '' ? ' - '. $session_info['comment'] : '');
$data['session_info'] = $session_info;
$data['cabrillo'] = $cabrillo;
$data['qso_count'] = $this->contesting_model->get_session_qso_count($contest_session_id);
$data['user_name'] = trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
$data['user_email'] = $userinfo->user_email;
$data['session_operators'] = $session_operators;
$data['contest_session_id'] = $contest_session_id;
$this->load->view('interface_assets/header', $data);
$this->load->view('contesting/manager/export_cbr');
$this->load->view('interface_assets/footer');
}
/**
* EDI Export Page for contests
*/
public function export_edi($contest_session_id) {
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($contest_session_id)) {
$this->session->set_flashdata('error', __("Contest session not found."));
redirect('contesting');
}
$session_info = $this->contesting_model->get_session_info($contest_session_id);
$reg1test = $this->contesting_model->get_exportformat_settings($contest_session_id, "reg1test");
$userinfo = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();
$session_operators = $this->contesting_model->get_session_operators($contest_session_id);
$session_bands = $this->contesting_model->get_session_bands($contest_session_id);
$effective_name = !empty($session_info['custom_name']) ? $session_info['custom_name'] : $session_info['contest_name'];
$data['page_title'] = sprintf(__("Export: %s"), $effective_name) . ($session_info['comment'] != '' ? ' - '. $session_info['comment'] : '');
$data['session_info'] = $session_info;
$data['reg1test'] = $reg1test;
$data['bands'] = $session_bands;
$data['qso_count'] = $this->contesting_model->get_session_qso_count($contest_session_id);
$data['user_name'] = trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
$data['user_email'] = $userinfo->user_email;
$data['session_operators'] = $session_operators;
$data['contest_session_id'] = $contest_session_id;
$this->load->view('interface_assets/header', $data);
$this->load->view('contesting/manager/export_edi');
$this->load->view('interface_assets/footer');
}
/**
* ADIF export for a specific contest session.
* POST /contesting/export_adif/
*/
public function export_adif($contest_session_id) {
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($contest_session_id) || !clubaccess_check(6)) {
show_404();
}
$session_info = $this->contesting_model->get_session_info($contest_session_id);
$qsos = $this->contesting_model->get_session_qsos_for_adif($contest_session_id);
$callsign = strtoupper(str_replace('/', '-', $session_info['station_callsign'] ?? 'STATION'));
$contest = strtoupper($session_info['contest_adifname'] ?? 'CONTEST');
$filename = $callsign . '-' . $contest . '-' . date('Ymd') . '.adi';
$this->load->library('AdifHelper');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo $this->adifhelper->getAdifHeader(
$this->config->item('app_name'),
$this->optionslib->get_option('version'),
$this->optionslib->get_option('adif_version')
);
foreach ($qsos->result() as $qso) {
echo $this->adifhelper->getAdifLine($qso);
}
}
/**
* Cabrillo export for a specific contest session.
* Saves Cabrillo category settings back to the session before streaming the file.
* POST /contesting/export_cabrillo/
*/
public function export_cabrillo($contest_session_id) {
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($contest_session_id) || !clubaccess_check(6)) {
show_404();
}
$cabrillo = [
'category_operator' => $this->input->post('categoryoperator', true) ?? 'SINGLE-OP',
'category_assisted' => $this->input->post('categoryassisted', true) ?? 'NON-ASSISTED',
'category_band' => $this->input->post('categoryband', true) ?? 'ALL',
'category_mode' => $this->input->post('categorymode', true) ?? 'MIXED',
'category_power' => $this->input->post('categorypower', true) ?? 'LOW',
'category_station' => $this->input->post('categorystation', true) ?? 'FIXED',
'category_transmitter' => $this->input->post('categorytransmitter', true) ?? 'ONE',
'category_time' => $this->input->post('categorytime', true) ?? '',
'category_overlay' => $this->input->post('categoryoverlay', true) ?? '',
'club' => $this->input->post('club', true) ?? '',
'location' => $this->input->post('location', true) ?? '',
'operators' => $this->input->post('operators', true) ?? '',
'name' => $this->input->post('cbr_name', true) ?? '',
'email' => $this->input->post('cbr_email', true) ?? '',
'address' => $this->input->post('address', true) ?? '',
'addresscity' => $this->input->post('addresscity', true) ?? '',
'addressprovince' => $this->input->post('addressprovince', true) ?? '',
'addresspostalcode' => $this->input->post('addresspostalcode', true) ?? '',
'addresscountry' => $this->input->post('addresscountry', true) ?? '',
'soapbox' => $this->input->post('soapbox', true) ?? '',
'certificate' => $this->input->post('certificate', true) ?? '',
'grid_export' => $this->input->post('grid_export', true) ?? '0',
'grid_precision' => $this->input->post('grid_precision', true) === '6' ? '6' : '4',
];
$this->contesting_model->save_exportformat_settings($contest_session_id, "cabrillo", $cabrillo);
$session_info = $this->contesting_model->get_session_info($contest_session_id);
$qsos = $this->contesting_model->get_session_qsos_for_exportformat($contest_session_id);
$userinfo = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();
$contest_id = $session_info['contest_adifname'];
$callsign = strtoupper(str_replace('/', '-', $session_info['station_callsign'] ?? 'STATION'));
$filename = $callsign . '-' . $contest_id . '-' . date('Ymd-Hi') . '.cbr';
$this->load->library('Cabrilloformat');
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$grid_export = ($cabrillo['grid_export'] === '1');
$grid_chars = (int)($cabrillo['grid_precision'] ?? 4);
$cbr_name = $cabrillo['name'] ?: trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
$cbr_email = $cabrillo['email'] ?: $userinfo->user_email;
echo $this->cabrilloformat->header(
$contest_id,
$callsign,
null,
$cabrillo['operators'] ?: $callsign,
$cabrillo['club'] ?: null,
$cabrillo['location'] ?: null,
$cbr_name,
$cabrillo['address'] ?? '',
$cabrillo['addresscity'] ?? '',
$cabrillo['addressprovince'] ?? '',
$cabrillo['addresspostalcode'] ?? '',
$cabrillo['addresscountry'] ?? '',
$cabrillo['soapbox'] ?: '',
$session_info['station_gridsquare'] ?: null,
$cabrillo['category_overlay'] ?: '',
$cabrillo['category_transmitter'],
$cabrillo['category_time'] ?: '',
$cabrillo['category_station'],
$cabrillo['category_power'],
$cabrillo['category_mode'],
$cabrillo['category_band'],
$cabrillo['category_assisted'],
$cabrillo['category_operator'],
$cbr_email,
$cabrillo['certificate'] ?: null
);
foreach ($qsos->result() as $qso) {
echo $this->cabrilloformat->qso($qso, $grid_export, $grid_chars);
}
echo $this->cabrilloformat->footer();
}
/**
* EDI (Reg1Test) export for a specific contest session.
* Saves Reg1Test settings back to the session before streaming the file.
* POST /contesting/export_reg1test/
*/
public function export_reg1test($contest_session_id) {
//load contesting model
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
//security checks
if (!$this->contesting_model->check_user_contest($contest_session_id) || !clubaccess_check(6)) {
show_404();
}
//load distance calculator
$this->load->library('Qra');
//load userinfo
$userinfo = $this->user_model->get_by_id($this->session->userdata('user_id'))->row();
//get session information
$session_info = $this->contesting_model->get_session_info($contest_session_id);
//get reg1test settings from user input
$reg1test_settings = [
'contestband' => $this->input->post('contestband', true),
'sentexchange' => $this->input->post('sentexchange', true) ?? '',
'contestaddress1' => $this->input->post('contestaddress1', true) ?? '',
'contestaddress2' => $this->input->post('contestaddress2', true) ?? '',
'categoryoperator' => $this->input->post('categoryoperator', true) ?? 'SINGLE-OP',
'club' => $this->input->post('club', true) ?? '',
'responsible_operator' => $this->input->post('responsible_operator', true) ?? '',
'address1' => $this->input->post('address1', true) ?? '',
'address2' => $this->input->post('address2', true) ?? '',
'addresspostalcode' => $this->input->post('addresspostalcode', true) ?? '',
'addresscity' => $this->input->post('addresscity', true) ?? '',
'addresscountry' => $this->input->post('addresscountry', true) ?? '',
'operatorphone' => $this->input->post('operatorphone', true) ?? '',
'operators' => $this->input->post('operators', true) ?? '',
'txequipment' => $this->input->post('txequipment', true) ?? '',
'power' => $this->input->post('power', true) ?? '',
'rxequipment' => $this->input->post('rxequipment', true) ?? '',
'antenna' => $this->input->post('antenna', true) ?? '',
'antennaheight' => $this->input->post('antennaheight', true) ?? '',
'soapbox' => $this->input->post('soapbox', true) ?? '',
'bandmultiplicator' => $this->input->post('bandmultiplicator', true) ?? '',
];
//save reg1test settings
$this->contesting_model->save_exportformat_settings($contest_session_id, "reg1test", $reg1test_settings);
//take reg1testsettings and use that as base for export data structure
$data = $reg1test_settings;
//get qsos and set qso data for export
$data['qsos'] = $this->contesting_model->get_session_qsos($contest_session_id, $data['contestband']);
//set contest header data for export
$data['band'] = $this->input->post('contestband', true);
$data['qso_count'] = count($data['qsos']);
$data['contest_id'] = $session_info['contest_adifname'];
$data['callsign'] = strtoupper(str_replace('/', '-', $session_info['station_callsign'] ?? 'STATION'));
$data['gridlocator'] = $session_info['station_gridsquare'];
$data['name'] = trim($userinfo->user_firstname . ' ' . $userinfo->user_lastname);
$data['maxdistanceqso'] = $this->qra->getMaxDistanceQSO($session_info['station_gridsquare'], $data['qsos'], "K");
$data['from'] = date('Ymd', strtotime($session_info['time_start']));
$data['to'] = date('Ymd', strtotime($session_info['time_end']));
//Load distance calculator
$this->load->library('Reg1testformat');
//Set headers
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $data['callsign'] . '-' . $data['contest_id'] . '-' . date('Ymd-Hi') . '-' . $this->reg1testformat->reg1testbandstring($data['band']) . '.edi"');
//calculate qso details
$qsodetails = $this->reg1testformat->qsos($data['qsos'], $data['gridlocator'], $data['bandmultiplicator']);
//get header
echo $this->reg1testformat->header(
$data['contest_id'],
$data['from'],
$data['to'],
$data['callsign'],
$data['gridlocator'],
$data['contestaddress1'],
$data['contestaddress2'],
$data['categoryoperator'],
$data['band'],
$data['club'],
$data['name'],
$data['responsible_operator'],
$data['address1'],
$data['address2'],
$data['addresspostalcode'],
$data['addresscity'],
$data['addresscountry'],
$data['operatorphone'],
$data['operators'],
$data['soapbox'],
$data['qso_count'],
$data['sentexchange'],
$data['txequipment'],
$data['power'],
$data['rxequipment'],
$data['antenna'],
$data['antennaheight'],
$data['maxdistanceqso'],
$data['bandmultiplicator'],
$qsodetails['claimedpoints']
);
//write QSO details
echo $qsodetails['formatted_qso'];
//get seperate footer if QSO details won't provide one
echo $data['qso_count'] < 1 ? $this->reg1testformat->footer() : '';
}
/**
* This starts the main logging engine for contesting
*/
public function logging_engine($contest_session_id = 0) {
$this->load->model('contesting_model');
$contest_session_id = intval($contest_session_id);
// Security Check: Ensure the user is authorized for this contest session
if (!$contest_session_id || !$this->contesting_model->check_user_contest($contest_session_id)) {
$this->session->set_flashdata('error', __("You are not authorized to access this contest session."));
redirect('contesting');
}
// setting up worker if available
$worker_topic = 'contest_session.' . $contest_session_id; // shared topic for all operators in this contest session
if ($this->worker_available) {
$this->worker->register_topic($worker_topic);
$data['worker_client_url'] = $this->worker->client_url();
$data['worker_topic'] = $worker_topic;
$data['worker_token'] = $this->worker->create_token($worker_topic);
}
// Load session data
$data['session_info'] = $this->contesting_model->get_session_info($contest_session_id);
if (!$data['session_info']) {
$this->session->set_flashdata('error', __("Contest session not found."));
redirect('contesting');
}
// if cache driver is dummy and serial numbers are required, log an error message
if (in_array('serial', $data['session_info']['exchangefields'] ?? [], true)
&& $this->cache->get_loaded_driver() === 'dummy') {
log_message('error', 'Contest logger: no cache is available (dummy adapter), so serial numbers cannot be handed out per operator. Configure Redis/Valkey, Memcached, APCu or the file cache.');
}
// Per-user map preferences (night shadow / path line / station marker).
// Stored in user_options (bound to user_id), default all on except grid (needs more ressources)
$this->load->model('user_options_model');
$map_prefs = ['nightshadow' => true, 'pathline' => true, 'station' => true, 'autofit' => true, 'grid' => false];
$pref_row = $this->user_options_model->get_options('contest_logger_settings', ['option_name' => 'map_prefs']);
if ($pref_row && $pref_row->num_rows() > 0) {
$stored = json_decode($pref_row->row()->option_value, true);
if (is_array($stored)) {
$map_prefs = array_merge($map_prefs, $stored);
}
}
$data['map_prefs'] = $map_prefs;
// Generate storage key for localStorage. This needs to be collision-free between different Wavelog Instances and different users
$data['storage_key'] = md5($this->config->item('base_url') . $contest_session_id . $this->session->userdata('user_id'));
$data['operator'] = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
$data['page_title'] = !empty($data['session_info']['custom_name'])
? $data['session_info']['custom_name']
: $data['session_info']['contest_name'];
$data['is_club_station'] = (bool) ($this->session->userdata('clubstation') ?? false);
$data['switch_operator_mode'] = null;
if ($data['is_club_station'] && !($this->config->item('disable_switch_operator') ?? false)) {
if (empty($this->session->userdata('source_uid'))) {
$data['switch_operator_mode'] = 'callsign';
} elseif (!$this->config->item('disable_impersonate')) {
$data['switch_operator_mode'] = 'login';
}
}
$data['club_callsign'] = $this->session->userdata('user_callsign');
// Load available radios for CAT control
$this->load->model('cat');
$data['radios'] = $this->cat->radios();
// Register a worker topic per radio so the browser can subscribe for real-time CAT updates
$radio_worker_topics = [];
if ($this->worker_available) {
foreach ($data['radios']->result() as $radio) {
$radio_topic = 'radio.' . $radio->id;
$this->worker->register_topic($radio_topic);
$radio_worker_topics[$radio->id] = [
'topic' => $radio_topic,
'token' => $this->worker->create_token($radio_topic),
];
}
}
$data['radio_worker_topics'] = $radio_worker_topics;
// Load available modes for manual frequency/mode entry
$this->load->model('usermodes');
$data['modes'] = $this->usermodes->active();
// Load Bands
$this->load->model('bands');
$data['bands'] = $this->bands->get_user_bands_for_qso_entry();
log_message('debug', 'Loaded bands for contest logger: ' . json_encode($data['bands']));
// Set custom date format
if ($this->session->userdata('user_date_format')) {
$data['custom_date_format'] = $this->session->userdata('user_date_format');
} else {
$data['custom_date_format'] = $this->config->item('qso_date_format');
}
// Component and layout configuration (percentage-based: 0-100)
// Component names as keys, layout configuration as values
// This can later be overridden by user-defined layouts
$data['components'] = [
'qso-form' => [
'x' => 1, // 1% from left
'y' => 1, // 1% from top
'width' => 70, // 70% of workspace width
'height' => 60, // 60% of workspace height
],
'radio' => [
'x' => 73, // 73% from left (right side)
'y' => 1, // 1% from top
'width' => 26, // 26% of workspace width
'height' => 30, // 30% of workspace height
],
'clock' => [
'x' => 1, // 1% from left
'y' => 63, // 63% from top (below qso-form)
'width' => 20, // 20% of workspace width
'height' => 12, // 12% of workspace height
],
'map' => [
'x' => 22, // 22% from left (next to clock)
'y' => 63, // 63% from top (below qso-form)
'width' => 51, // 51% of workspace width
'height' => 36, // 36% of workspace height
],
'scp' => [
'x' => 73, // 73% from left (right side)
'y' => 32, // 32% from top (below radio)
'width' => 26, // 26% of workspace width
'height' => 67, // 67% of workspace height
],
'statistics' => [
'x' => 1, // 1% from left, below clock
'y' => 76, // clock ends at y=75 (63+12), so 1% gap
'width' => 20, // same width as clock for visual alignment
'height' => 22, // fills down to ~98%
]
];
if ($this->session->userdata('isWinkeyEnabled')) {
// Winkeyer sits at the bottom right (y=63). Shrink the SCP component
// above it so both fit stacked without the winkeyer overlapping the SCP.
$data['components']['scp']['height'] = 30; // 32% + 30% = 62%, leaving a 1% gap before the winkeyer at y=63
$data['components']['winkeyer'] = [
'x' => 73,
'y' => 63,
'width' => 26,
'height' => 35,
];
}
$this->load->view('contesting/logger/header', $data);
$this->load->view('contesting/logger/index');
$this->load->view('contesting/logger/footer');
}
public function create_session() {
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
switch ($this->input->method()) {
case 'get':
$this->load->is_loaded('contest_admin_model') ?: $this->load->model('contest_admin_model');
$this->load->is_loaded('stations') ?: $this->load->model('stations');
$data['available_contests'] = $this->contest_admin_model->getActiveContests();
if (!empty($this->session->userdata('user_stations_active_log_only'))) {
$data['stations'] = $this->logbooks_model->list_logbooks_linked($this->session->userdata('active_station_logbook'));
} else {
$data['stations'] = $this->stations->all_of_user();
}
$data['active_station_location'] = $this->active_station_location;
$this->load->view('contesting/manager/components/session_modal', $data);
break;
case 'post':
if (!clubaccess_check(9)) {
$this->session->set_flashdata('error', __("Officers must set up contests."));
redirect('contesting');
}
$contest_adif_id = $this->input->post('contest_adif_id', true);
$session_start = $this->input->post('session_start', true);
$session_end = $this->input->post('session_end', true);
$station_location = $this->input->post('station_location', true);
$session_notes = $this->input->post('session_notes', true);
$exchangefields = $this->_parseExchangeFields($this->input->post('exchangefields', true));
$exchangetype = $this->_fieldsToLegacyType($exchangefields);
$copyexchangeto = $this->input->post('copyexchangeto', true) ?? '';
$callbook_lookup = (bool) $this->input->post('callbook_lookup', true);
$custom_name = trim($this->input->post('custom_name', true) ?? '');
$serial_per_band = (bool) $this->input->post('serial_per_band', true);
$serial_scope = $this->_parseSerialScope($this->input->post('serial_scope', true));
$parameter_array = [
'exchangetype' => $exchangetype,
'copyexchangeto' => $copyexchangeto,
'exchangefields' => $exchangefields,
'callbook_lookup' => $callbook_lookup,
'custom_name' => $custom_name,
'serial_per_band' => $serial_per_band,
'serial_scope' => $serial_scope,
];
$result = $this->contesting_model->create_contest_session($contest_adif_id, $session_start, $session_end, $station_location, $session_notes, false, $parameter_array);
if ($result) {
$this->session->set_flashdata('success', __("Contest session created successfully."));
} else {
$this->session->set_flashdata('error', __("There was an error creating the contest session. Please try again."));
}
redirect('contesting');
default:
$this->session->set_flashdata('error', __("Invalid request method."));
redirect('contesting');
}
}
public function edit_session(){
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
switch ($this->input->method()) {
case 'get':
$contest_session_id = $this->input->get('contest_session_id');
$this->load->is_loaded('contest_admin_model') ?: $this->load->model('contest_admin_model');
$this->load->is_loaded('stations') ?: $this->load->model('stations');
$data['session_info'] = $this->contesting_model->get_session_info($contest_session_id);
if (!$data['session_info']) {
$this->session->set_flashdata('error', __("Contest session not found."));
redirect('contesting');
}
$data['available_contests'] = $this->contest_admin_model->getActiveContests();
$data['stations'] = $this->stations->all_of_user();
$data['active_station_location'] = $this->active_station_location;
$this->load->view('contesting/manager/components/session_modal', $data);
break;
case 'post':
$contest_session_id = $this->input->post('contest_session_id', true);
$time_start = $this->input->post('session_start', true);
$time_end = $this->input->post('session_end', true);
$station_id = $this->input->post('station_location', true);
$notes = $this->input->post('session_notes', true);
$contest_id = $this->input->post('contest_adif_id', true);
$exchangefields = $this->_parseExchangeFields($this->input->post('exchangefields', true));
$exchangetype = $this->_fieldsToLegacyType($exchangefields);
$copyexchangeto = $this->input->post('copyexchangeto', true) ?? '';
$callbook_lookup = (bool) $this->input->post('callbook_lookup', true);
$custom_name = trim($this->input->post('custom_name', true) ?? '');
$serial_per_band = (bool) $this->input->post('serial_per_band', true);
$serial_scope = $this->_parseSerialScope($this->input->post('serial_scope', true));
$parameter_array = [
'exchangetype' => $exchangetype,
'copyexchangeto' => $copyexchangeto,
'exchangefields' => $exchangefields,
'callbook_lookup' => $callbook_lookup,
'custom_name' => $custom_name,
'serial_per_band' => $serial_per_band,
'serial_scope' => $serial_scope,
];
$result = $this->contesting_model->update_contest_session($contest_session_id, $contest_id, $time_start, $time_end, $station_id, $notes, $parameter_array);
if ($result) {
$this->cache->delete(self::CACHE_KEY_SESSION_SETTINGS . $contest_session_id); // Clear session cache to reflect changes immediately
if ($this->worker_available) {
$this->worker->publish('contest_session.' . $contest_session_id, ['type' => 'settings_changed']);
}
$this->session->set_flashdata('success', __("Contest session updated successfully."));
} else {
$this->session->set_flashdata('error', __("There was an error updating the contest session. Please try again."));
}
redirect('contesting');
default:
$this->session->set_flashdata('error', __("Invalid request method."));
redirect('contesting');
}
}
public function delete_session(){
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
switch ($this->input->method()) {
case 'get':
$contest_session_id = $this->input->get('contest_session_id');
$this->load->is_loaded('contest_admin_model') ?: $this->load->model('contest_admin_model');
$this->load->is_loaded('stations') ?: $this->load->model('stations');
$data['session_info'] = $this->contesting_model->get_session_info($contest_session_id);
if (!$data['session_info']) {
$this->session->set_flashdata('error', __("Contest session not found."));
redirect('contesting');
}
$data['available_contests'] = $this->contest_admin_model->getActiveContests();
if (!empty($this->session->userdata('user_stations_active_log_only'))) {
$data['stations'] = $this->logbooks_model->list_logbooks_linked($this->session->userdata('active_station_logbook'));
} else {
$data['stations'] = $this->stations->all_of_user();
}
$data['active_station_location'] = $this->active_station_location;
$this->load->view('contesting/manager/components/confirm_delete', $data);
break;
case 'post':
$contest_session_id = $this->input->post('contest_session_id', true);
$delete_qsos = (bool) $this->input->post('delete_qsos', true);
$result = $this->contesting_model->delete_contest_session($contest_session_id, $delete_qsos);
if ($result) {
$this->session->set_flashdata('success', __("Contest session deleted successfully."));
} else {
$this->session->set_flashdata('error', __("There was an error deleting the contest session. Please try again."));
}
redirect('contesting');
default:
$this->session->set_flashdata('error', __("Invalid request method."));
redirect('contesting');
}
}
/**
* Deletes multiple contest sessions at once.
* Endpoint: POST /contesting/batch_delete_sessions
* POST: ids = comma-separated contest_session ids, delete_qsos = optional flag
*/
public function batch_delete_sessions() {
if ($this->input->method() !== 'post') {
$this->session->set_flashdata('error', __("Invalid request method."));
redirect('contesting');
}
if (!clubaccess_check(9)) {
$this->session->set_flashdata('error', __("Only clubstation officers can delete."));
redirect('contesting');
}
$ids = array_filter(array_map('intval', explode(',', (string)$this->input->post('ids', true))));
$delete_qsos = (bool) $this->input->post('delete_qsos', true);
if (empty($ids)) {
$this->session->set_flashdata('error', __("No contest sessions selected."));
redirect('contesting');
}
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
$deleted = $this->contesting_model->batch_delete_sessions($ids, $delete_qsos);
if ($deleted > 0) {
$this->session->set_flashdata('success', sprintf(_ngettext("%d contest session deleted.", "%d contest sessions deleted.", $deleted), $deleted));
} else {
$this->session->set_flashdata('error', __("There was an error deleting the contest sessions. Please try again."));
}
redirect('contesting');
}
/**
* Inline QSO edit endpoint.
* Endpoint: POST /contesting/update_qso
*
* Accepts JSON: { contest_session_id, qso_id, callsign?, mode?, frequency?,
* rst_sent?, rst_rcvd?, serial_sent?, serial_rcvd?,
* exchange_sent?, exchange_rcvd?, gridsquare_rcvd? }
*
* Authorization: PHP session user must own the contest session AND be the
* operator recorded on the QSO (operator check).
*/
public function update_qso() {
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
header('Content-Type: application/json');
try {
$payload = json_decode($this->input->raw_input_stream, true);
if (!$payload) {
throw new Exception('Invalid JSON payload');
}
$contest_session_id = (int)($payload['contest_session_id'] ?? 0);
$qso_id = (int)($payload['qso_id'] ?? 0);
if (!$contest_session_id || !$qso_id) {
throw new Exception('Missing contest_session_id or qso_id');
}
$this->load->model('contesting_model');
$this->load->is_loaded('logbook_model') ?: $this->load->model('logbook_model');
// Session ownership check
if (!$this->contesting_model->check_user_contest($contest_session_id)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Access denied']);
return;
}
// Verify QSO belongs to this session and get its operator
$qso = $this->contesting_model->get_contest_qso($qso_id, $contest_session_id);
if (!$qso) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'QSO not found in this session']);
return;
}
// Operator check: only the user who logged the QSO may edit it.
// In club-station mode user_callsign is the club callsign; operator_callsign holds the personal callsign that was written to COL_OPERATOR.
$current_callsign = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
$qso_operator = strtoupper(trim($qso['operator'] ?? ''));
if ($qso_operator !== $current_callsign) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'You can only edit QSOs you logged']);
return;
}
// Whitelist of editable columns
$allowed = [
'callsign' => 'COL_CALL',
'mode' => 'COL_MODE',
'frequency' => 'COL_FREQ',
'band' => 'COL_BAND',
'rst_sent' => 'COL_RST_SENT',
'rst_rcvd' => 'COL_RST_RCVD',
'serial_sent' => 'COL_STX',
'serial_rcvd' => 'COL_SRX',
'exchange_sent' => 'COL_STX_STRING',
'exchange_rcvd' => 'COL_SRX_STRING',
'gridsquare_rcvd' => 'COL_GRIDSQUARE',
'time_on' => 'COL_TIME_ON',
];
$fields = [];
foreach ($allowed as $key => $col) {
if (array_key_exists($key, $payload)) {
$val = $payload[$key];
if (in_array($key, ['callsign', 'mode', 'rst_sent', 'rst_rcvd',
'serial_sent', 'serial_rcvd', 'exchange_sent',
'exchange_rcvd', 'gridsquare_rcvd'])) {
$val = $val !== null ? strtoupper(trim((string)$val)) : null;
if ($key === 'callsign' && $val !== null) {
$val = $this->_validateCallsign($val);
}
}
// Bands are stored lowercase (e.g. 20m, 70cm)
if ($key === 'band') {
$val = $val !== null ? strtolower(trim((string)$val)) : null;
}
// Frequency is user-editable now; normalize to an Hz integer (also accepts unit suffixes).
if ($key === 'frequency') {
$val = ($val !== null && trim((string)$val) !== '') ? $this->logbook_model->parse_frequency((string)$val) : null;
}
if (in_array($key, ['serial_sent', 'serial_rcvd']) && $val === '') {
$val = null;
}
if ($key === 'time_on' && $val !== null) {
$dt = DateTime::createFromFormat('Y-m-d H:i:s', trim((string)$val));
// getLastErrors() catches overflow (e.g. 25:00:00 silently wrapping to next day)
$dt_errors = DateTime::getLastErrors();
if (!$dt || ($dt_errors && array_sum($dt_errors))) throw new Exception('Invalid time_on value');
$val = $dt->format('Y-m-d H:i:s');
}
$fields[$col] = $val;
if ($key === 'time_on') $fields['COL_TIME_OFF'] = $val;
}
}
if (empty($fields)) {
throw new Exception('No editable fields provided');
}
$this->contesting_model->update_contest_qso($qso_id, $fields);
if ($this->worker_available) {
$this->worker->publish('contest_session.' . $contest_session_id, ['type' => 'sync_required']);
}
echo json_encode(['success' => true, 'qso_id' => $qso_id]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}
/**
* Delete a single QSO from a contest session and from the main logbook.
* Endpoint: POST /contesting/delete_qso
*
* Accepts JSON: { contest_session_id, qso_id }
*/
public function delete_qso() {
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
header('Content-Type: application/json');
try {
$payload = json_decode($this->input->raw_input_stream, true);
if (!$payload) {
throw new Exception('Invalid JSON payload');
}
$contest_session_id = (int)($payload['contest_session_id'] ?? 0);
$qso_id = (int)($payload['qso_id'] ?? 0);
if (!$contest_session_id || !$qso_id) {
throw new Exception('Missing contest_session_id or qso_id');
}
$this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($contest_session_id)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Access denied']);
return;
}
$qso = $this->contesting_model->get_contest_qso($qso_id, $contest_session_id);
if (!$qso) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'QSO not found in this session']);
return;
}
$current_callsign = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
$qso_operator = strtoupper(trim($qso['operator'] ?? ''));
if ($qso_operator !== $current_callsign) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'You can only delete QSOs you logged']);
return;
}
$this->contesting_model->unlink_qso($qso_id, $contest_session_id);
$this->load->model('logbook_model');
$this->logbook_model->delete($qso_id);
if ($this->worker_available) {
$this->worker->publish('contest_session.' . $contest_session_id, ['type' => 'sync_required']);
}
echo json_encode(['success' => true, 'qso_id' => $qso_id]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}
/**
* Switch the operating user mid-session in a club station.
*/
public function switch_operator() {
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
header('Content-Type: application/json');
try {
if (!$this->config->item('special_callsign') || $this->config->item('disable_impersonate')) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => __("Operator switching is disabled.")]);
return;
}
$payload = json_decode($this->input->raw_input_stream, true);
if (!$payload) {
throw new Exception('Invalid JSON payload');
}
$contest_session_id = (int)($payload['contest_session_id'] ?? 0);
$user_name = trim((string)($payload['user_name'] ?? ''));
$user_password = (string)($payload['user_password'] ?? '');
if (!$contest_session_id || $user_name === '' || $user_password === '') {
throw new Exception('Missing required fields');
}
// Only valid inside a club station.
if ((int)$this->session->userdata('clubstation') !== 1) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => __("Operator switching is only available in club stations.")]);
return;
}
$club_id = (int)$this->session->userdata('user_id');
// The club must own this contest session.
$this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($contest_session_id)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => __("Access denied")]);
return;
}
// Re-authenticate the new operator. Reuses bcrypt verification and the
// login_attempts lockout. Returns 1 on success; 0/2/3 are all failures here.
if ($this->user_model->authenticate($user_name, $user_password) !== 1) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => __("Incorrect username or password!")]);
return;
}
$op = $this->user_model->get($user_name)->row();
// A club account must never act as an operator. authenticate() lets club
// accounts through when club_direct is enabled, so reject them explicitly.
if ((int)$op->clubstation !== 0) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => __("A club account cannot be used as an operator.")]);
return;
}
// The operator must be an authorised member (p_level >= 3) of THIS club.
// get_permission_noui() is the redirect-free getter (club_authorize() would
// redirect on failure, which breaks a JSON endpoint).
$this->load->model('club_model');
$p_level = (int)$this->club_model->get_permission_noui($club_id, $op->user_id);
if ($p_level < 3) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => __("You're not allowed to do that!")]);
return;
}
// Establish the impersonation session for the new operator (same primitives
// as User::impersonate()). user_id stays the club.
// Create a fresh impersonation hash so the operator can still use "Switch back"
// to return to their own account. Format must match stop_impersonate()'s
// validation (User.php): source_uid/target_uid/timestamp — source is this
// operator, target is the club (which stays as user_id).
if (!$this->load->is_loaded('encryption')) {
$this->load->library('encryption');
}
$custom = [
'p_level' => $p_level,
'src_call' => $op->user_callsign,
'src_user_type' => $op->user_type,
'src_hash' => $this->encryption->encrypt($op->user_id . '/' . $club_id . '/' . time()),
];
$this->session->set_userdata('source_uid', $op->user_id);
$this->user_model->update_session($club_id, null, true, $custom);
// update_session keeps an existing operator_callsign, so override it explicitly.
$this->session->set_userdata('operator_callsign', strtoupper(trim($op->user_callsign)));
// Reload the engine; it picks up the new operator from the session.
echo json_encode([
'success' => true,
'redirect' => site_url('contesting/logging_engine') . '/' . intval($contest_session_id),
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}
/**
* Resolves which serial pool a request belongs to.
*
* @param array $session_info Result of Contesting_model::get_session_info()
* @param string|null $band Band reported by the client.
* @return array [band, operator], each null when the pool is not scoped by it.
*/
private function _serialPool($session_info, $band = null) {
$pool_band = null;
$pool_operator = null;
if (!empty($session_info['serial_per_band'])) {
// Format check only, no db required here
$band = strtolower(trim($band ?? ''));
$pool_band = preg_match('/^\d{1,4}(\.\d{1,2})?(mm|cm|m)$/', $band) ? $band : '';
}
if (($session_info['serial_scope'] ?? 'station') === 'operator') {
$pool_operator = strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign')));
}
return [$pool_band, $pool_operator];
}
/**
* Sync Endpoint for Contest Engine
* Handles bidirectional communication (Commands + Requests)
* Endpoint: POST /contesting/heartbeat
*/
public function heartbeat() {
// Only accept POST requests
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
header('Content-Type: application/json');
session_write_close();
try {
// Get JSON payload
$json_input = $this->input->raw_input_stream;
$payload = json_decode($json_input, true);
if (!$payload) {
throw new Exception('Invalid JSON payload');
}
$session_id = (int)($payload['session_info']['contest_session_id'] ?? 0);
if (!$session_id) {
throw new Exception('Missing contest_session_id');
}
$this->load->model('contesting_model');
if (!$this->contesting_model->check_user_contest($session_id)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Access denied']);
return;
}
$session_info = $this->contesting_model->get_session_info($session_id);
if (!$session_info) {
throw new Exception('Contest session not found');
}
$response = [
'success' => true,
'commands_processed' => 0,
'data' => [
'saved_qsos' => [],
'needs_resync' => false,
'all_qsos' => null,
'changed_qsos' => []
],
'errors' => []
];
// Process Commands (Push Operations from client to server)
if (isset($payload['commands']) && is_array($payload['commands'])) {
foreach ($payload['commands'] as $command) {
try {
$response['commands_processed'] += $this->_processCommand($command, $session_info);
} catch (Exception $e) {
$response['errors'][] = $e->getMessage();
}
}
}
// Transfer saved QSOs from commands to response
$response['data']['saved_qsos'] = $this->new_qsos;
// Process Requests (Pull Operations from server to client)
if (isset($payload['requests']) && is_array($payload['requests'])) {
foreach ($payload['requests'] as $request) {
try {
$this->_processRequest($request, $session_info, $response);
} catch (Exception $e) {
$response['errors'][] = $e->getMessage();
}
}
}
echo json_encode($response);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
'data' => []
]);
}
}
/**
* Process a single command (server write operation)
* @private
*/
private function _processCommand($command, $session_info) {
if (!isset($command['type'])) {
throw new Exception('Command missing type');
}
switch ($command['type']) {
case 'save_qso':
if (!isset($command['data'])) {
throw new Exception('save_qso command missing data');
}
$command['data']['callsign'] = $this->_validateCallsign($command['data']['callsign'] ?? '');
// Only load models if needed
$this->load->is_loaded('logbook_model') ?: $this->load->model('logbook_model');
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
// Prepare QSO data for saving
$qso_data = [
'manual' => 0, // work always as non-manual entry; TODO: Implement something like POST CONTEST LOGGING
'start_date' => $command['data']['date'],
'start_time' => $command['data']['time'],
'end_time' => $command['data']['time'],
'callsign' => $command['data']['callsign'],
'freq_display' => $command['data']['frequency'],
'mode' => $command['data']['mode'],
'rst_sent' => $command['data']['rst_sent'],
'rst_rcvd' => $command['data']['rst_rcvd'],
'exch_serial_s' => $command['data']['serial_sent'] ?? NULL,
'exch_serial_r' => $command['data']['serial_rcvd'] ?? NULL,
'exch_sent' => $command['data']['exchange_sent'] ?? NULL,
'exch_rcvd' => $command['data']['exchange_rcvd'] ?? NULL,
'locator' => $command['data']['gridsquare_rcvd'] ?? NULL,
'name' => $command['data']['name'] ?? NULL,
'qth' => $command['data']['qth'] ?? NULL,
'ituz' => $command['data']['ituz'] ?? NULL,
'country' => $command['data']['country'] ?? NULL,
'continent' => $command['data']['continent'] ?? NULL,
'dxcc_id' => $command['data']['dxcc_id'] ?? NULL,
'cqz' => $command['data']['cqz'] ?? NULL,
'operator_callsign' => strtoupper(trim($this->session->userdata('operator_callsign') ?: $this->session->userdata('user_callsign'))),
'station_profile' => $session_info['station_id'],
'contestname' => $session_info['contest_adifname'],
'exchangetype' => $session_info['exchangetype'] ?? 'Exchange',
'copyexchangeto' => $session_info['copyexchangeto'] ?? NULL
];
// Save QSO to database
$save_result = $this->logbook_model->create_qso($qso_data, false);
// Link QSO to contest session
if (is_array($save_result) && !empty($save_result['qso_id'])) {
$this->contesting_model->link_qso($save_result['qso_id'], $session_info['contest_session_id']);
// Keep the counter ahead of the log: if the operator overrode the
// serial in the form by hand, the counter would otherwise hand the
// same number out again.
if (!empty($command['data']['serial_sent'])) {
list($pool_band, $pool_operator) = $this->_serialPool($session_info, $command['data']['band'] ?? null);
$this->contesting_model->serial_bump(
$session_info['contest_session_id'],
$pool_band,
$pool_operator,
$command['data']['serial_sent']
);
}
// Notify worker clients about new QSO if worker is available
if ($this->worker_available) {
$this->worker->publish('contest_session.' . $session_info['contest_session_id'], ['type' => 'sync_required']);
log_message('debug', 'published sync_required for contest session ' . $session_info['contest_session_id']);
}
} else {
throw new Exception('Failed to save QSO');;
}
// Store mapping of tmp_id to real ID for client response
// This will be sent back to the client to update local state.
// last_modified_ms lets the client advance its since_ts watermark so the
// next check_sync does not pull this QSO back as a delta. The row was just
// inserted with last_modified = NOW(), so the current server time in ms
// matches it at the second precision the delta query compares on.
$this->new_qsos[] = [
'tmp_id' => $command['data']['tmp_id'],
'server_id' => $save_result['qso_id'],
'last_modified_ms' => time() * 1000
];
return 1; // 1 command processed
default:
throw new Exception("Unknown command type: {$command['type']}");
}
}
/**
* Process a single request (server read operation)
* @private
*/
private function _processRequest($request, $session_info, &$response) {
if (!isset($request['type'])) {
throw new Exception('Request missing type');
}
switch ($request['type']) {
case 'get_radio_status':
// Radio CAT status request
if (!isset($request['radio_id']) || $request['radio_id'] === '0') {
break; // No radio selected
}
$this->load->model('cat');
$radio_query = $this->cat->radio_status($request['radio_id']);
if ($radio_query->num_rows() > 0) {
$row = $radio_query->row();
$response['data']['radio_status'] = [
'frequency' => $row->frequency,
'mode' => $row->mode,
'timestamp' => strtotime($row->timestamp) * 1000, // Unix timestamp in ms
'updated_minutes_ago' => floor((time() - strtotime($row->timestamp)) / 60)
];
} else {
$response['data']['radio_status'] = null;
}
break;
case 'check_sync':
// Incremental sync: deliver only QSOs changed since the client's watermark.
// Adds and edits (contest editor AND external logbook/ADIF/QSL edits) bump
// last_modified, so the delta query catches them all. Deletes do not bump
// anything (the row is gone), so they are caught by the QSO count check below,
// which falls back to a one-off full resync — rare and acceptable.
$client_qso_count = $request['client_qso_count'] ?? 0;
$since_ts = (int)($request['since_ts'] ?? 0);
$since_id = (int)($request['since_id'] ?? 0);
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
$server_qso_count = $this->contesting_model->get_session_qso_count($session_info['contest_session_id']);
// Make server count available to response without an extra query later
$response['server_qso_count'] = $server_qso_count;
// Expected client count after the client applies the QSOs saved this heartbeat
$expected_client_count = $client_qso_count + count($response['data']['saved_qsos']);
if ($expected_client_count !== $server_qso_count) {
// Count mismatch → a delete (or a gap we cannot express as a delta) happened.
// Send the full set so the client can replace its local state.
$response['data']['needs_resync'] = true;
$response['data']['all_qsos'] = $this->_map_qso_ids(
$this->contesting_model->get_session_qsos_since($session_info['contest_session_id'], 0, 0)
);
log_message('info', "Resync triggered for session {$session_info['contest_session_id']}: Client={$client_qso_count} + Saved=" . count($response['data']['saved_qsos']) . " = {$expected_client_count}, Server={$server_qso_count}");
} else {
// Normal case → only the QSOs changed since the client's watermark.
$response['data']['needs_resync'] = false;
$response['data']['changed_qsos'] = $this->_map_qso_ids(
$this->contesting_model->get_session_qsos_since($session_info['contest_session_id'], $since_ts, $since_id)
);
}
break;
case 'get_session_settings':
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
$cache_key = self::CACHE_KEY_SESSION_SETTINGS . $session_info['contest_session_id'];
$cached_settings = $this->cache->get($cache_key);
log_message('info', "Cache lookup for session settings with key {$cache_key}: " . ($cached_settings ? 'HIT' : 'MISS'));
if ($cached_settings) {
$response['data']['session_settings'] = $cached_settings;
break;
}
$fresh = $this->contesting_model->get_session_info($session_info['contest_session_id']);
$response['data']['session_settings'] = $fresh;
$this->cache->save($cache_key, $response['data']['session_settings'], 3600); // Cache for 1h
break;
case 'serial_state':
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
list($pool_band, $pool_operator) = $this->_serialPool($session_info, $request['band'] ?? null);
$response['data']['next_serial'] = $this->contesting_model->serial_peek(
$session_info['contest_session_id'],
$pool_band,
$pool_operator
);
break;
case 'claim_serial':
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
list($pool_band, $pool_operator) = $this->_serialPool($session_info, $request['band'] ?? null);
$claimed = $this->contesting_model->serial_claim(
$session_info['contest_session_id'],
$pool_band,
$pool_operator
);
$response['data']['claimed_serial'] = ($claimed === false) ? null : $claimed;
// Let the other operators refresh their preview right away.
if ($claimed !== false && $this->worker_available) {
$this->worker->publish('contest_session.' . $session_info['contest_session_id'], ['type' => 'serial_changed']);
}
break;
case 'release_serial':
$this->load->is_loaded('contesting_model') ?: $this->load->model('contesting_model');
list($pool_band, $pool_operator) = $this->_serialPool($session_info, $request['band'] ?? null);
$released = $this->contesting_model->serial_release(
$session_info['contest_session_id'],
$pool_band,
$pool_operator,
$request['serial'] ?? 0
);
$response['data']['next_serial'] = $this->contesting_model->serial_peek(
$session_info['contest_session_id'],
$pool_band,
$pool_operator
);
// Only a successful rollback changes anything for the others.
if ($released && $this->worker_available) {
$this->worker->publish('contest_session.' . $session_info['contest_session_id'], ['type' => 'serial_changed']);
}
break;
default:
throw new Exception("Unknown request type: {$request['type']}");
}
}
/**
* Maps each QSO's qso_id to an id field for frontend compatibility.
* @param array|mixed $qsos Result of a get_session_qsos* query
* @return array
*/
private function _map_qso_ids($qsos) {
if (!is_array($qsos)) {
return [];
}
return array_map(function ($qso) {
$qso['id'] = $qso['qso_id'];
return $qso;
}, $qsos);
}
/**
* Get all saved layouts for user
* POST: /contesting/get_layouts
*/
public function get_layouts() {
header('Content-Type: application/json');
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
try {
$this->load->model('user_options_model');
$result = $this->user_options_model->get_options('contest_logger_layout');
// Get default layout name
$default_result = $this->user_options_model->get_options('contest_logger_settings', ['option_name' => 'default_layout']);
$default_layout = null;
if ($default_result && $default_result->num_rows() > 0) {
$default_layout = $default_result->row()->option_value;
}
$layouts = [];
if ($result && $result->num_rows() > 0) {
foreach ($result->result() as $row) {
$layouts[] = [
'name' => $row->option_name,
'data' => json_decode($row->option_value, true),
'is_default' => ($row->option_name === $default_layout)
];
}
}
echo json_encode([
'success' => true,
'layouts' => $layouts,
'default_layout' => $default_layout
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Set default layout for contest logger
* POST: /contesting/set_default_layout
*/
public function set_default_layout() {
header('Content-Type: application/json');
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
try {
$json_input = $this->input->raw_input_stream;
$payload = json_decode($json_input, true);
if (!$payload || !isset($payload['name'])) {
throw new Exception('Missing required field: name');
}
$name = $payload['name'];
// Save default layout preference
$this->load->model('user_options_model');
$this->user_options_model->set_option(
'contest_logger_settings',
'default_layout',
['value' => $name]
);
echo json_encode([
'success' => true,
'message' => 'Default layout set successfully'
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Save per-user contest map preferences (night shadow / path line / station marker).
* POST: /contesting/save_map_prefs
* Stored per-user in user_options (bound to user_id), so other users on the same
* device do not inherit them.
*/
public function save_map_prefs() {
header('Content-Type: application/json');
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
try {
$payload = json_decode($this->input->raw_input_stream, true);
if (!is_array($payload)) {
throw new Exception('Invalid payload');
}
// Whitelist + normalise to booleans
$prefs = [
'nightshadow' => !empty($payload['nightshadow']),
'pathline' => !empty($payload['pathline']),
'station' => !empty($payload['station']),
'autofit' => !empty($payload['autofit']),
'grid' => !empty($payload['grid']),
];
$this->load->model('user_options_model');
$this->user_options_model->set_option(
'contest_logger_settings',
'map_prefs',
['value' => json_encode($prefs)]
);
echo json_encode(['success' => true]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Save user layout for contest logger
* POST: /contesting/save_layout
*/
public function save_layout() {
header('Content-Type: application/json');
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
try {
$json_input = $this->input->raw_input_stream;
$payload = json_decode($json_input, true);
if (!$payload || !isset($payload['layout']) || !isset($payload['name'])) {
throw new Exception('Missing required fields: layout or name');
}
$layout = $payload['layout'];
$name = trim($payload['name']);
if (empty($name)) {
throw new Exception('Layout name cannot be empty');
}
// Save layout to user_options
$this->load->model('user_options_model');
$this->user_options_model->set_option(
'contest_logger_layout',
$name,
['data' => json_encode($layout)]
);
echo json_encode([
'success' => true,
'message' => 'Layout saved successfully'
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Load user layout for contest logger
* POST: /contesting/load_layout
*/
public function load_layout() {
header('Content-Type: application/json');
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
try {
$json_input = $this->input->raw_input_stream;
$payload = json_decode($json_input, true);
if (!$payload || !isset($payload['name'])) {
throw new Exception('Missing required field: name');
}
$name = $payload['name'];
// Load layout from user_options
$this->load->model('user_options_model');
$result = $this->user_options_model->get_options(
'contest_logger_layout',
['option_name' => $name]
);
if ($result && $result->num_rows() > 0) {
$row = $result->row();
$layout = json_decode($row->option_value, true);
echo json_encode([
'success' => true,
'layout' => $layout
]);
} else {
echo json_encode([
'success' => true,
'layout' => null
]);
}
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/**
* Delete user layout for contest logger
* POST: /contesting/delete_layout
*/
public function delete_layout() {
header('Content-Type: application/json');
if ($this->input->method() !== 'post') {
$this->_teapot();
return;
}
try {
$json_input = $this->input->raw_input_stream;
$payload = json_decode($json_input, true);
if (!$payload || !isset($payload['name'])) {
throw new Exception('Missing required field: name');
}
$name = $payload['name'];
// Delete layout from user_options
$this->load->model('user_options_model');
// Delete specific layout
$this->user_options_model->del_option(
'contest_logger_layout',
$name
);
echo json_encode([
'success' => true,
'message' => 'Layout deleted successfully'
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
}
/*
* Combined DXCC + callbook lookup for the contest engine.
* DXCC comes from the local library (fast, no network). Callbook data (name, QTH,
* grid, etc.) is fetched externally and merged on top, overriding DXCC fields where
* the callbook has more specific data. Result is cached server-side for 4 hours.
*/
public function callbook() {
$this->load->library('callbook');
$call = $this->input->get('call', TRUE);
if (!$call || strlen($call) < 3) {
http_response_code(400);
echo json_encode(['error' => 'invalid call']);
return;
}
$call = $this->callbook->get_plaincall($call);
$offline_lookup = $this->input->get('offline', TRUE) === '1' ? true : false;
// Local DXCC lookup — always available, no network needed
$date = date("Y-m-d");
$dxccobj = new Dxcc();
$dxcc = $dxccobj->dxcc_lookup($call, $date);
$payload = [
'name' => null,
'qth' => null,
'grid' => null,
'ituz' => null,
'dxcc' => $dxcc['adif'] ?? null,
'cqz' => $dxcc['cqz'] ?? null,
'cont' => $dxcc['cont'] ?? null,
'entity' => $dxcc['entity'] ?? null,
'lat' => $dxcc['lat'] ?? null,
'long' => $dxcc['long'] ?? null,
'source' => null,
];
$this->load->model('logbook_model');
// DB-first: get all available info from own log
$db_info = $this->logbook_model->get_callsign_all_info($call);
$payload['name'] = $db_info['name'] ?: null;
$payload['qth'] = $db_info['qth'] ?: null;
$payload['grid'] = $db_info['qra'] ?: null;
$payload['ituz'] = $db_info['ituz'] ?: null;
if (!empty($db_info['cqz'])) $payload['cqz'] = $db_info['cqz'];
if ($payload['grid']) $payload['source'] = 'log';
if (!$offline_lookup) {
// we want to cache callbook lookups :)
$cache_key = 'contesting_callbook_' . strtolower($call);
$cbdata = $this->cache->get($cache_key);
if ($cbdata === FALSE) {
$cbdata = $this->logbook_model->loadCallBook($call);
if (!empty($cbdata) && empty($cbdata['error'])) {
$this->cache->save($cache_key, $cbdata, 14400); // 4h cache time for online lookups
}
}
if (!empty($cbdata) && empty($cbdata['error'])) {
if (empty($payload['name'])) $payload['name'] = $cbdata['name'] ?? null;
if (empty($payload['qth'])) $payload['qth'] = $cbdata['city'] ?? null;
if (!empty($cbdata['gridsquare'])) $payload['grid'] = $cbdata['gridsquare'];
if (empty($payload['ituz'])) $payload['ituz'] = $cbdata['ituz'] ?? null;
if (!empty($cbdata['dxcc'])) $payload['dxcc'] = $cbdata['dxcc'];
if (!empty($cbdata['cqz'])) $payload['cqz'] = $cbdata['cqz'];
$payload['source'] = $cbdata['source'] ?? null;
}
}
header('Content-Type: application/json');
echo json_encode($payload);
}
private function _parseExchangeFields($json) {
$allowed = ['serial', 'gridsquare', 'exchange'];
$decoded = json_decode($json ?? '', true);
if (!is_array($decoded)) {
return ['exchange'];
}
$fields = array_values(array_filter($decoded, fn($f) => in_array($f, $allowed)));
return $fields ?: ['exchange'];
}
/**
* Normalise the serial number scope of a contest session.
*
* @param string|null $scope Raw POST value.
* @return string Either 'station' or 'operator'.
*/
private function _parseSerialScope($scope) {
return in_array($scope, ['station', 'operator'], true) ? $scope : 'station';
}
private function _fieldsToLegacyType($fields) {
$s = in_array('serial', $fields);
$g = in_array('gridsquare', $fields);
$e = in_array('exchange', $fields);
if ($s && $g && $e) return 'SerialGridExchange';
if ($s && $g) return 'Serialgridsquare';
if ($s && $e) return 'Serialexchange';
if ($e && $g) return 'Exchangegridsquare';
if ($s) return 'Serial';
return 'Exchange';
}
/**
* Validate and normalise a contest callsign.
*
* Mirrors the client-side validation in qso-form.js: only A-Z, 0-9 and the
* special character "/" are permitted. The "?" wildcard is allowed
* while searching in the UI but must be resolved before a QSO is saved, so a
* callsign still containing it is rejected here.
*
* @param string $callsign
* @return string normalised callsign (uppercase, Ø→0)
* @throws Exception if the callsign is empty or contains illegal characters
*/
private function _validateCallsign($callsign) {
$call = str_replace('Ø', '0', strtoupper(trim((string)$callsign)));
if ($call === '' || !preg_match('/^[A-Z0-9\/]+$/', $call)) {
throw new Exception('Invalid callsign');
}
return $call;
}
private function _teapot() {
http_response_code(418);
echo json_encode(['success' => false, 'error' => "I'm a teapot. Don't brew coffee with me."]);
}
}