diff --git a/application/config/migration.php b/application/config/migration.php index 77ec7206e..46009ce35 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ | */ -$config['migration_version'] = 264; +$config['migration_version'] = 265; /* |-------------------------------------------------------------------------- diff --git a/application/controllers/Api.php b/application/controllers/Api.php index 119f43221..8b8ae5432 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -177,6 +177,85 @@ function station_info($key) } } + /** + * Get station logbooks that have public slugs for the API key owner. + * GET /api/logbook_public_slugs/{key} + */ + function logbook_public_slugs($key) + { + header('Content-type: application/json'); + + $this->load->model('api_model'); + $this->load->model('logbooks_model'); + + if (!$key || $this->api_model->authorize($key) == 0) { + http_response_code(401); + echo json_encode(['status' => 'failed', 'reason' => 'missing or invalid api key']); + return; + } + + $this->api_model->update_last_used($key); + $user_id = $this->api_model->key_userid($key); + + $query = $this->logbooks_model->public_slugs_by_user($user_id); + $logbooks = array(); + + foreach ($query->result() as $row) { + $logbooks[] = array( + 'logbook_id' => (int)$row->logbook_id, + 'logbook_name' => $row->logbook_name, + 'public_slug' => $row->public_slug, + ); + } + + http_response_code(200); + echo json_encode(array( + 'status' => 'success', + 'count' => count($logbooks), + 'logbooks' => $logbooks, + ), JSON_PRETTY_PRINT); + } + + /** + * Get all accessible station logbooks (owned + shared) that have public slugs for the API key owner. + * GET /api/logbook_public_slugs_accessible/{key} + */ + function logbook_public_slugs_accessible($key) + { + header('Content-type: application/json'); + + $this->load->model('api_model'); + $this->load->model('logbooks_model'); + + if (!$key || $this->api_model->authorize($key) == 0) { + http_response_code(401); + echo json_encode(['status' => 'failed', 'reason' => 'missing or invalid api key']); + return; + } + + $this->api_model->update_last_used($key); + $user_id = $this->api_model->key_userid($key); + + $query = $this->logbooks_model->public_slugs_accessible_by_user($user_id); + $logbooks = array(); + + foreach ($query->result() as $row) { + $logbooks[] = array( + 'logbook_id' => (int)$row->logbook_id, + 'logbook_name' => $row->logbook_name, + 'public_slug' => $row->public_slug, + 'access_level' => $row->access_level, + ); + } + + http_response_code(200); + echo json_encode(array( + 'status' => 'success', + 'count' => count($logbooks), + 'logbooks' => $logbooks, + ), JSON_PRETTY_PRINT); + } + /* * @@ -594,6 +673,187 @@ function logbook_check_country() } } + /** + * Check worked and confirmation status for callsign and country. + * + * @api POST /api/logbook_worked_status + * @header Content-Type application/json + * + * Required fields: + * - key + * - logbook_public_slug + * - callsign + * + * Optional fields: + * - band + * - mode + * - gridsquare + */ + function logbook_worked_status() + { + header('Content-type: application/json'); + + $this->load->model('api_model'); + + $obj = json_decode(file_get_contents("php://input"), true); + if ($obj === NULL) { + http_response_code(400); + echo json_encode(['status' => 'failed', 'reason' => "wrong JSON"]); + return; + } + + if (!isset($obj['key']) || $this->api_model->authorize($obj['key']) == 0) { + http_response_code(401); + echo json_encode(['status' => 'failed', 'reason' => "missing api key"]); + return; + } + + if (!isset($obj['logbook_public_slug']) || !isset($obj['callsign'])) { + http_response_code(401); + echo json_encode(['status' => 'failed', 'reason' => "missing fields"]); + return; + } + + $this->load->model('logbook_model'); + $this->load->model('logbooks_model'); + + $logbook_slug = trim($obj['logbook_public_slug']); + $callsign = strtoupper(trim($obj['callsign'])); + $band = isset($obj['band']) && trim($obj['band']) !== '' ? strtoupper(trim($obj['band'])) : null; + $mode = isset($obj['mode']) && trim($obj['mode']) !== '' ? strtoupper(trim($obj['mode'])) : null; + $gridsquare = isset($obj['gridsquare']) && trim($obj['gridsquare']) !== '' ? strtoupper(trim($obj['gridsquare'])) : null; + + $date = date("Y-m-d"); + $callsign_dxcc_lookup = $this->logbook_model->dxcc_lookup($callsign, $date); + $country = isset($callsign_dxcc_lookup['entity']) ? $callsign_dxcc_lookup['entity'] : ''; + + if (!$this->logbooks_model->public_slug_exists($logbook_slug)) { + http_response_code(404); + echo json_encode(['status' => 'failed', 'reason' => "logbook not found"]); + return; + } + + $logbook_id = $this->logbooks_model->public_slug_exists_logbook_id($logbook_slug); + if ($logbook_id == false) { + http_response_code(404); + echo json_encode(['status' => 'failed', 'reason' => $logbook_slug . " has no associated station locations"]); + return; + } + + $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($logbook_id); + if (!$logbooks_locations_array) { + http_response_code(404); + echo json_encode(['status' => 'failed', 'reason' => "Empty Logbook"]); + return; + } + + $return = [ + 'callsign' => $callsign, + 'country' => $country, + 'band' => $band, + 'mode' => $mode, + 'gridsquare' => $gridsquare, + 'worked' => [ + 'callsign_overall' => false, + 'callsign_band' => $band !== null ? false : null, + 'country_overall' => false, + 'country_band' => $band !== null ? false : null, + 'grid_overall' => $gridsquare !== null ? false : null, + 'grid_band' => ($gridsquare !== null && $band !== null) ? false : null, + ], + 'confirmed' => [ + 'callsign' => [ + 'lotw' => false, + 'qsl' => false, + 'any' => false, + ], + 'country' => [ + 'lotw' => false, + 'qsl' => false, + 'any' => false, + ], + ], + ]; + + $table_name = $this->config->item('table_name'); + $main_mode = $mode !== null ? $this->logbook_model->get_main_mode_from_mode($mode) : null; + + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_CALL', $callsign); + $callsign_overall_query = $this->db->get($table_name, 1, 0); + $return['worked']['callsign_overall'] = $callsign_overall_query->num_rows() > 0; + + if ($band !== null) { + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_CALL', $callsign); + $this->db->where('COL_BAND', $band); + if ($main_mode !== null) { + $this->db->where('COL_MODE', $main_mode); + } + $callsign_band_query = $this->db->get($table_name, 1, 0); + $return['worked']['callsign_band'] = $callsign_band_query->num_rows() > 0; + } + + if ($country !== '') { + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_COUNTRY', urldecode($country)); + $country_overall_query = $this->db->get($table_name, 1, 0); + $return['worked']['country_overall'] = $country_overall_query->num_rows() > 0; + + if ($band !== null) { + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_COUNTRY', urldecode($country)); + $this->db->where('COL_BAND', $band); + if ($main_mode !== null) { + $this->db->where('COL_MODE', $main_mode); + } + $country_band_query = $this->db->get($table_name, 1, 0); + $return['worked']['country_band'] = $country_band_query->num_rows() > 0; + } + } + + if ($gridsquare !== null) { + $grid_overall_result = $this->logbook_model->check_if_grid_worked_in_logbook($gridsquare, $logbooks_locations_array, null); + $return['worked']['grid_overall'] = $grid_overall_result > 0; + + if ($band !== null) { + $grid_band_result = $this->logbook_model->check_if_grid_worked_in_logbook($gridsquare, $logbooks_locations_array, $band); + $return['worked']['grid_band'] = $grid_band_result > 0; + } + } + + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_CALL', $callsign); + $this->db->where("COL_LOTW_QSL_RCVD='Y'"); + $callsign_lotw_query = $this->db->get($table_name, 1, 0); + $return['confirmed']['callsign']['lotw'] = $callsign_lotw_query->num_rows() > 0; + + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_CALL', $callsign); + $this->db->where("COL_QSL_RCVD='Y'"); + $callsign_qsl_query = $this->db->get($table_name, 1, 0); + $return['confirmed']['callsign']['qsl'] = $callsign_qsl_query->num_rows() > 0; + $return['confirmed']['callsign']['any'] = $return['confirmed']['callsign']['lotw'] || $return['confirmed']['callsign']['qsl']; + + if ($country !== '') { + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_COUNTRY', urldecode($country)); + $this->db->where("COL_LOTW_QSL_RCVD='Y'"); + $country_lotw_query = $this->db->get($table_name, 1, 0); + $return['confirmed']['country']['lotw'] = $country_lotw_query->num_rows() > 0; + + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_COUNTRY', urldecode($country)); + $this->db->where("COL_QSL_RCVD='Y'"); + $country_qsl_query = $this->db->get($table_name, 1, 0); + $return['confirmed']['country']['qsl'] = $country_qsl_query->num_rows() > 0; + $return['confirmed']['country']['any'] = $return['confirmed']['country']['lotw'] || $return['confirmed']['country']['qsl']; + } + + http_response_code(200); + echo json_encode($return, JSON_PRETTY_PRINT); + } + /* ENDPOINT for Rig Control */ function radio() diff --git a/application/controllers/Logbook.php b/application/controllers/Logbook.php index 939a6ffc2..756285e22 100644 --- a/application/controllers/Logbook.php +++ b/application/controllers/Logbook.php @@ -92,6 +92,30 @@ function jsonentity($adif) echo json_encode($return, JSON_PRETTY_PRINT); } + function jsondxcc($tempcallsign) + { + $callsign = $this->security->xss_clean($tempcallsign); + + $this->load->model('user_model'); + if (!$this->user_model->authorize($this->config->item('auth_mode'))) { + return; + } + + // Convert - in Callsign to / Used for URL processing + $callsign = str_replace("-", "/", $callsign); + $callsign = str_replace("Ø", "0", $callsign); + + $return = [ + "callsign" => strtoupper($callsign), + "dxcc" => false, + ]; + + $return['dxcc'] = $this->cached_dxcc_lookup($callsign); + + header('Content-Type: application/json'); + echo json_encode($return, JSON_PRETTY_PRINT); + } + function json($tempcallsign, $temptype, $tempband, $tempmode, $tempstation_id = null) { // Cleaning for security purposes @@ -134,18 +158,25 @@ function json($tempcallsign, $temptype, $tempband, $tempmode, $tempstation_id = "qsl_manager" => "", "bearing" => "", "workedBefore" => false, + "callsignWorkedBefore" => false, + "callsignConfirmed" => false, "timesWorked" => 0, "lotw_member" => $lotw_member, "lotw_days" => $lotw_days, "image" => "", ]; - $return['dxcc'] = $this->dxcheck($callsign); + $return['dxcc'] = $this->cached_dxcc_lookup($callsign); $lookupcall = $this->get_plaincall($callsign); $return['partial'] = $this->partial($lookupcall); + $this->load->model('logbooks_model'); + $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); + $user_default_confirmation = $this->session->userdata('user_default_confirmation'); + $recent_details = $this->logbook_model->get_recent_callsign_details($callsign, $this->session->userdata('user_id')); + $callbook = $this->logbook_model->loadCallBook($callsign, $this->config->item('use_fullname')); if ($this->session->userdata('user_measurement_base') == NULL) { @@ -154,16 +185,19 @@ function json($tempcallsign, $temptype, $tempband, $tempmode, $tempstation_id = $measurement_base = $this->session->userdata('user_measurement_base'); } - $return['callsign_name'] = $this->nval($callbook['name'] ?? '', $this->logbook_model->call_name($callsign)); - $return['callsign_qra'] = $this->nval($callbook['gridsquare'] ?? '', $this->logbook_model->call_qra($callsign)); + $return['callsign_name'] = $this->nval($callbook['name'] ?? '', $recent_details['name']); + $return['callsign_qra'] = $this->nval($callbook['gridsquare'] ?? '', $recent_details['gridsquare']); $return['callsign_distance'] = $this->distance($return['callsign_qra']); - $return['callsign_qth'] = $this->nval($callbook['city'] ?? '', $this->logbook_model->call_qth($callsign)); - $return['callsign_iota'] = $this->nval($callbook['iota'] ?? '', $this->logbook_model->call_iota($callsign)); - $return['qsl_manager'] = $this->nval($callbook['qslmgr'] ?? '', $this->logbook_model->call_qslvia($callsign)); - $return['callsign_state'] = $this->nval($callbook['state'] ?? '', $this->logbook_model->call_state($callsign)); - $return['callsign_us_county'] = $this->nval($callbook['us_county'] ?? '', $this->logbook_model->call_us_county($callsign)); - $return['workedBefore'] = $this->worked_grid_before($return['callsign_qra'], $type, $band, $mode); - $return['confirmed'] = $this->confirmed_grid_before($return['callsign_qra'], $type, $band, $mode); + $return['callsign_qth'] = $this->nval($callbook['city'] ?? '', $recent_details['qth']); + $return['callsign_iota'] = $this->nval($callbook['iota'] ?? '', $recent_details['iota']); + $return['qsl_manager'] = $this->nval($callbook['qslmgr'] ?? '', $recent_details['qsl_via']); + $return['callsign_state'] = $this->nval($callbook['state'] ?? '', $recent_details['state']); + $return['callsign_us_county'] = $this->nval($callbook['us_county'] ?? '', $recent_details['us_county']); + $return['workedBefore'] = $this->worked_grid_before($return['callsign_qra'], $type, $band, $mode, $logbooks_locations_array); + $return['confirmed'] = $this->confirmed_grid_before($return['callsign_qra'], $type, $band, $mode, $logbooks_locations_array, $user_default_confirmation); + $callsign_status = $this->callsign_status($callsign, $type, $band, $mode, $logbooks_locations_array, $user_default_confirmation); + $return['callsignWorkedBefore'] = $callsign_status['workedBefore']; + $return['callsignConfirmed'] = $callsign_status['confirmed']; $return['timesWorked'] = $this->logbook_model->times_worked($lookupcall); if ($this->session->userdata('user_show_profile_image')) { @@ -213,39 +247,109 @@ function nval($val1, $val2) return (($val2 ?? "") === "" ? ($val1 ?? "") : ($val2 ?? "")); } - function confirmed_grid_before($gridsquare, $type, $band, $mode) + private function build_confirmation_where($user_default_confirmation) { - if (strlen($gridsquare) < 4) - return false; - - $this->load->model('logbooks_model'); - $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); - $user_default_confirmation = $this->session->userdata('user_default_confirmation'); - - if (!empty($logbooks_locations_array)) { - $extrawhere = ''; - if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'Q') !== false) { - $extrawhere = "COL_QSL_RCVD='Y'"; + $extrawhere = ''; + if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'Q') !== false) { + $extrawhere = "COL_QSL_RCVD='Y'"; + } + if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'L') !== false) { + if ($extrawhere != '') { + $extrawhere .= " OR"; } - if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'L') !== false) { - if ($extrawhere != '') { - $extrawhere .= " OR"; - } - $extrawhere .= " COL_LOTW_QSL_RCVD='Y'"; + $extrawhere .= " COL_LOTW_QSL_RCVD='Y'"; + } + if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'E') !== false) { + if ($extrawhere != '') { + $extrawhere .= " OR"; } - if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'E') !== false) { - if ($extrawhere != '') { - $extrawhere .= " OR"; - } - $extrawhere .= " COL_EQSL_QSL_RCVD='Y'"; + $extrawhere .= " COL_EQSL_QSL_RCVD='Y'"; + } + + if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'Z') !== false) { + if ($extrawhere != '') { + $extrawhere .= " OR"; } + $extrawhere .= " COL_QRZCOM_QSO_DOWNLOAD_STATUS='Y'"; + } - if (isset($user_default_confirmation) && strpos($user_default_confirmation, 'Z') !== false) { - if ($extrawhere != '') { - $extrawhere .= " OR"; - } - $extrawhere .= " COL_QRZCOM_QSO_DOWNLOAD_STATUS='Y'"; + return $extrawhere; + } + + private function callsign_status($callsign, $type, $band, $mode, $logbooks_locations_array, $user_default_confirmation) + { + $return = [ + "workedBefore" => false, + "confirmed" => false, + ]; + + if (empty($logbooks_locations_array)) { + return $return; + } + + $this->load->model('logbook_model'); + + if ($type == "SAT") { + $this->db->where('COL_PROP_MODE', 'SAT'); + } else { + $this->db->where('COL_MODE', $this->logbook_model->get_main_mode_from_mode($mode)); + $this->db->where('COL_BAND', $band); + $this->db->where('COL_PROP_MODE !=', 'SAT'); + } + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_CALL', strtoupper($callsign)); + + $query = $this->db->get($this->config->item('table_name'), 1, 0); + foreach ($query->result() as $workedBeforeRow) { + $return['workedBefore'] = true; + } + + $extrawhere = $this->build_confirmation_where($user_default_confirmation); + + if ($type == "SAT") { + $this->db->where('COL_PROP_MODE', 'SAT'); + if ($extrawhere != '') { + $this->db->where('(' . $extrawhere . ')'); + } else { + $this->db->where("1=0"); } + } else { + $this->load->model('logbook_model'); + $this->db->where('COL_MODE', $this->logbook_model->get_main_mode_from_mode($mode)); + $this->db->where('COL_BAND', $band); + $this->db->where('COL_PROP_MODE !=', 'SAT'); + if ($extrawhere != '') { + $this->db->where('(' . $extrawhere . ')'); + } else { + $this->db->where("1=0"); + } + } + $this->db->where_in('station_id', $logbooks_locations_array); + $this->db->where('COL_CALL', strtoupper($callsign)); + + $query = $this->db->get($this->config->item('table_name'), 1, 0); + foreach ($query->result() as $workedBeforeRow) { + $return['confirmed'] = true; + } + + return $return; + } + + function confirmed_grid_before($gridsquare, $type, $band, $mode, $logbooks_locations_array = null, $user_default_confirmation = null) + { + if (strlen($gridsquare) < 4) + return false; + + if ($logbooks_locations_array === null) { + $this->load->model('logbooks_model'); + $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); + } + if ($user_default_confirmation === null) { + $user_default_confirmation = $this->session->userdata('user_default_confirmation'); + } + + if (!empty($logbooks_locations_array)) { + $extrawhere = $this->build_confirmation_where($user_default_confirmation); if ($type == "SAT") { @@ -283,13 +387,15 @@ function confirmed_grid_before($gridsquare, $type, $band, $mode) return false; } - function worked_grid_before($gridsquare, $type, $band, $mode) + function worked_grid_before($gridsquare, $type, $band, $mode, $logbooks_locations_array = null) { if (strlen($gridsquare) < 4) return false; - $this->load->model('logbooks_model'); - $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); + if ($logbooks_locations_array === null) { + $this->load->model('logbooks_model'); + $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); + } if (!empty($logbooks_locations_array)) { if ($type == "SAT") { @@ -1186,6 +1292,30 @@ function dxcheck($call = "", $date = "") return $ans; } + private function cached_dxcc_lookup($call) + { + $normalized_call = strtoupper(trim((string)$call)); + if ($normalized_call === '') { + return false; + } + + $cache_key = 'dxcc_lookup_' . md5($normalized_call . '|' . date('Y-m-d')); + $cache_ttl = 3600; // 1 hour + + $this->load->driver('cache', array('adapter' => 'file', 'backup' => 'file')); + $cached = $this->cache->get($cache_key); + if ($cached !== false) { + return $cached; + } + + $dxcc = $this->dxcheck($normalized_call); + if ($dxcc !== false && $dxcc !== null) { + $this->cache->save($cache_key, $dxcc, $cache_ttl); + } + + return $dxcc; + } + function getentity($adif) { $this->load->model("logbook_model"); diff --git a/application/controllers/Qso.php b/application/controllers/Qso.php index ce3df2d71..b34b21ea7 100755 --- a/application/controllers/Qso.php +++ b/application/controllers/Qso.php @@ -122,6 +122,12 @@ public function index() 'operator_callsign' => $this->input->post('operator_callsign'), 'transmit_power' => $this->input->post('transmit_power') ); + + $propMode = strtoupper(trim((string)($qso_data['prop_mode'] ?? ''))); + if ($propMode !== 'SAT') { + $qso_data['sat_name'] = ''; + $qso_data['sat_mode'] = ''; + } // ]; setcookie("radio", $qso_data['radio'], time()+3600*24*99); @@ -162,6 +168,83 @@ public function saveqso() { $this->logbook_model->create_qso(); } + /* + * AJAX endpoint for QSO entry form to avoid full page reload on save. + */ + public function ajax_saveqso() { + $this->load->library('form_validation'); + $this->load->model('logbook_model'); + + $this->form_validation->set_rules('start_date', 'Date', 'required'); + $this->form_validation->set_rules('start_time', 'Time', 'required'); + $this->form_validation->set_rules('callsign', 'Callsign', 'required'); + $this->form_validation->set_rules('band', 'Band', 'required'); + $this->form_validation->set_rules('mode', 'Mode', 'required'); + $this->form_validation->set_rules('locator', 'Locator', 'callback_check_locator'); + + if ($this->form_validation->run() == FALSE) { + $validation_errors = array(); + $fields = array('start_date', 'start_time', 'callsign', 'band', 'mode', 'locator'); + foreach ($fields as $field) { + $field_error = form_error($field, '', ''); + if (!empty($field_error)) { + $validation_errors[$field] = strip_tags($field_error); + } + } + + return $this->output + ->set_content_type('application/json') + ->set_output(json_encode(array( + 'status' => 'error', + 'message' => 'Please correct the form errors and try again.', + 'validation_errors' => $validation_errors, + ))); + } + + $qso_data = array( + 'start_date' => $this->input->post('start_date'), + 'start_time' => $this->input->post('start_time'), + 'end_time' => $this->input->post('end_time'), + 'time_stamp' => time(), + 'band' => $this->input->post('band'), + 'band_rx' => $this->input->post('band_rx'), + 'freq' => $this->input->post('freq_display'), + 'freq_rx' => $this->input->post('freq_display_rx'), + 'mode' => $this->input->post('mode'), + 'sat_name' => $this->input->post('sat_name'), + 'sat_mode' => $this->input->post('sat_mode'), + 'prop_mode' => $this->input->post('prop_mode'), + 'radio' => $this->input->post('radio'), + 'station_profile_id' => $this->input->post('station_profile'), + 'operator_callsign' => $this->input->post('operator_callsign'), + 'transmit_power' => $this->input->post('transmit_power') + ); + + $propMode = strtoupper(trim((string)($qso_data['prop_mode'] ?? ''))); + if ($propMode !== 'SAT') { + $qso_data['sat_name'] = ''; + $qso_data['sat_mode'] = ''; + } + + setcookie("radio", $qso_data['radio'], time() + 3600 * 24 * 99); + setcookie("station_profile_id", $qso_data['station_profile_id'], time() + 3600 * 24 * 99); + + $this->session->set_userdata($qso_data); + + if ($this->input->post('sat_name')) { + $this->session->set_userdata('prop_mode', 'SAT'); + } + + $this->logbook_model->create_qso(); + + return $this->output + ->set_content_type('application/json') + ->set_output(json_encode(array( + 'status' => 'ok', + 'message' => 'QSO Added', + ))); + } + function edit() { $this->load->model('logbook_model'); @@ -740,6 +823,12 @@ public function component_past_contacts() { $data['current_page'] = $page; $data['limit'] = $limit; + // This endpoint is polled by HTMX and must not be cached. + $this->output->set_header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + $this->output->set_header('Cache-Control: post-check=0, pre-check=0', false); + $this->output->set_header('Pragma: no-cache'); + $this->output->set_header('Expires: Sat, 01 Jan 2000 00:00:00 GMT'); + // Load view $this->load->view('qso/components/previous_contacts', $data); } diff --git a/application/controllers/Stationdiary.php b/application/controllers/Stationdiary.php index bad844bbb..480e686b5 100644 --- a/application/controllers/Stationdiary.php +++ b/application/controllers/Stationdiary.php @@ -70,7 +70,7 @@ public function index($callsign = NULL, $offset = 0) $cleanCallsign = strtoupper($resolution['callsign']); $pageOffset = is_numeric($offset) ? (int)$offset : 0; $cacheVersion = $this->note->get_public_diary_cache_version($user_id); - $renderVersion = 'public_diary_render_v2'; + $renderVersion = 'public_diary_render_v5'; $cacheKey = 'public_station_diary_' . md5($cleanCallsign . '_' . $pageOffset . '_' . $cacheVersion . '_' . $renderVersion); $cachedHtml = $this->cache->get($cacheKey); @@ -110,12 +110,13 @@ public function index($callsign = NULL, $offset = 0) $this->pagination->initialize($config); $data['callsign'] = $cleanCallsign; - $data['entries'] = $this->note->get_public_station_diary_entries($user_id, $perPage, $pageOffset); + $data['entries'] = $this->note->get_public_station_diary_entries($user_id, $perPage, $pageOffset, FALSE); $data['pagination_links'] = $this->pagination->create_links(); $data['page_title'] = 'Station Diary - ' . $cleanCallsign; $data['rss_url'] = site_url('station-diary/' . rawurlencode($cleanCallsign) . '/rss'); $data['qso_datetime_format'] = $this->get_public_qso_datetime_format($resolution['user_date_format'] ?? NULL); $data['is_single_entry'] = false; + $data['defer_qso_list'] = true; $data['current_entry_permalink'] = ''; $html = $this->load->view('station_diary/public_index', $data, TRUE); @@ -192,6 +193,7 @@ public function entry($callsign = NULL, $entry_id = 0) $data['rss_url'] = site_url('station-diary/' . rawurlencode($cleanCallsign) . '/rss'); $data['qso_datetime_format'] = $this->get_public_qso_datetime_format($resolution['user_date_format'] ?? NULL); $data['is_single_entry'] = true; + $data['defer_qso_list'] = false; $data['current_entry_permalink'] = site_url('station-diary/' . rawurlencode($cleanCallsign) . '/entry/' . (int)$entry->id); $data['entry_reaction_totals'] = $entryReactionTotals; $data['visitor_reaction'] = null; @@ -247,7 +249,11 @@ public function react($callsign = NULL, $entry_id = 0) return; } - $this->note->invalidate_public_diary_cache_for_note($entryId, $user_id); + $cleanCallsign = strtoupper($resolution['callsign']); + $cacheVersion = $this->note->get_public_diary_cache_version($user_id); + $renderVersion = 'public_diary_render_v4'; + $entryCacheKey = 'public_station_diary_entry_' . md5($cleanCallsign . '_' . $entryId . '_' . $cacheVersion . '_' . $renderVersion); + $this->cache->delete($entryCacheKey); $totals = $this->note->get_station_diary_reaction_totals($entryId); $visitorReaction = $this->note->get_station_diary_visitor_reaction($entryId, $visitorHash); @@ -259,6 +265,67 @@ public function react($callsign = NULL, $entry_id = 0) )); } + public function get_filtered_qsos() + { + $this->output->set_content_type('application/json'); + + if (strtolower($this->input->method()) !== 'post') { + echo json_encode(array('success' => false, 'message' => 'Invalid request method')); + return; + } + + $callsign = $this->input->post('callsign', TRUE); + $entryId = (int)$this->input->post('entry_id', TRUE); + $startDate = trim((string)$this->input->post('start_date', TRUE)); + $endDate = trim((string)$this->input->post('end_date', TRUE)); + $logbookId = (int)$this->input->post('logbook_id', TRUE); + $satOnly = $this->input->post('sat_only', TRUE) === '1'; + + if ($this->security->xss_clean($callsign, TRUE) === FALSE || $entryId <= 0) { + echo json_encode(array('success' => false, 'message' => 'Invalid request')); + return; + } + + $resolution = $this->note->resolve_public_user_by_callsign($callsign); + if (!isset($resolution['status']) || $resolution['status'] !== 'ok') { + echo json_encode(array('success' => false, 'message' => 'Not found')); + return; + } + + $userId = (int)$resolution['user_id']; + $entry = $this->note->get_public_station_diary_entry($userId, $entryId); + if (!$entry) { + echo json_encode(array('success' => false, 'message' => 'Entry not found')); + return; + } + + $entryDate = date('Y-m-d', strtotime($entry->created_at)); + if ($startDate === '') { + $startDate = !empty($entry->qso_date_start) ? $entry->qso_date_start : $entryDate; + } + if ($endDate === '') { + $endDate = !empty($entry->qso_date_end) ? $entry->qso_date_end : $entryDate; + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { + echo json_encode(array('success' => false, 'message' => 'Invalid date format')); + return; + } + + if ($logbookId <= 0) { + $logbookId = !empty($entry->logbook_id) ? (int)$entry->logbook_id : 0; + } + + $qsoSummary = $this->note->get_qso_summary_for_date_range($userId, $startDate, $endDate, $logbookId, $satOnly); + $qsoList = $this->note->get_qso_list_for_date_range($userId, $startDate, $endDate, $logbookId, $satOnly); + + echo json_encode(array( + 'success' => true, + 'qso_list' => $qsoList, + 'highlight_dx' => $qsoSummary['highlight_dx'] ?? null, + )); + } + public function get_qso_map_data() { $this->output->set_content_type('application/json'); @@ -329,4 +396,5 @@ public function get_qso_map_data() // Merge and return echo json_encode(array_merge($plotArray, $stationArray)); - }} \ No newline at end of file + } +} \ No newline at end of file diff --git a/application/controllers/User.php b/application/controllers/User.php index dc4d6f676..409698e10 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -685,6 +685,8 @@ function edit() $data['dashboard_lotw_card'] = false; $data['dashboard_vuccgrids_card'] = false; $data['dashboard_map_greyline'] = true; + $data['menu_show_qsl_cards'] = true; + $data['menu_show_sstv_images'] = false; $dashboard_options = $this->user_options_model->get_options('dashboard')->result(); @@ -750,6 +752,17 @@ function edit() } } + $menu_options = $this->user_options_model->get_options('menu')->result(); + foreach ($menu_options as $item) { + if ($item->option_name == 'show_qsl_cards' && $item->option_key == 'enabled') { + $data['menu_show_qsl_cards'] = ($item->option_value == 'true'); + } + + if ($item->option_name == 'show_sstv_images' && $item->option_key == 'enabled') { + $data['menu_show_sstv_images'] = ($item->option_value == 'true'); + } + } + // [QSO Form] Load field visibility preferences $data['qso_fields'] = [ 'rst' => true, 'name' => true, 'qth' => true, 'locator' => true, 'comment' => true, @@ -934,6 +947,22 @@ function edit() $this->user_options_model->set_option('dashboard', 'dashboard_map_greyline', array('enabled' => 'false')); } + if (isset($_POST['user_menu_show_sstv_images'])) { + $this->user_options_model->set_option('menu', 'show_sstv_images', array('enabled' => 'true')); + $this->session->set_userdata('user_show_sstv_images', true); + } else { + $this->user_options_model->set_option('menu', 'show_sstv_images', array('enabled' => 'false')); + $this->session->set_userdata('user_show_sstv_images', false); + } + + if (isset($_POST['user_menu_show_qsl_cards'])) { + $this->user_options_model->set_option('menu', 'show_qsl_cards', array('enabled' => 'true')); + $this->session->set_userdata('user_show_qsl_cards', true); + } else { + $this->user_options_model->set_option('menu', 'show_qsl_cards', array('enabled' => 'false')); + $this->session->set_userdata('user_show_qsl_cards', false); + } + // [QSO Form] Save field visibility preferences $qso_field_keys = ['rst', 'name', 'qth', 'locator', 'comment', 'station_tab', 'freq_tx', 'freq_rx', 'band_rx', 'transmit_power', 'operator_callsign', diff --git a/application/migrations/265_add_station_time_on_index.php b/application/migrations/265_add_station_time_on_index.php new file mode 100644 index 000000000..eb26bec29 --- /dev/null +++ b/application/migrations/265_add_station_time_on_index.php @@ -0,0 +1,36 @@ +config->item('table_name'); + if (empty($table)) { + return; + } + + $this->db->db_debug = false; + $indexExists = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_station_time_on'")->num_rows(); + if ($indexExists == 0) { + $this->db->query("ALTER TABLE `{$table}` ADD INDEX `idx_station_time_on` (`station_id`, `COL_TIME_ON`)"); + } + $this->db->db_debug = true; + } + + public function down() + { + $table = $this->config->item('table_name'); + if (empty($table)) { + return; + } + + $this->db->db_debug = false; + $indexExists = $this->db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'idx_station_time_on'")->num_rows(); + if ($indexExists > 0) { + $this->db->query("ALTER TABLE `{$table}` DROP INDEX `idx_station_time_on`"); + } + $this->db->db_debug = true; + } +} diff --git a/application/migrations/266_tag_2_8_12.php b/application/migrations/266_tag_2_8_12.php new file mode 100644 index 000000000..640485d27 --- /dev/null +++ b/application/migrations/266_tag_2_8_12.php @@ -0,0 +1,30 @@ +db->where('option_name', 'version'); + $this->db->update('options', array('option_value' => '2.8.12')); + + // Trigger Version Info Dialog + $this->db->where('option_type', 'version_dialog'); + $this->db->where('option_name', 'confirmed'); + $this->db->update('user_options', array('option_value' => 'false')); + + } + + public function down() + { + $this->db->where('option_name', 'version'); + $this->db->update('options', array('option_value' => '2.8.11')); + } +} \ No newline at end of file diff --git a/application/models/Logbook_model.php b/application/models/Logbook_model.php index bb2dfbf10..5363310e0 100755 --- a/application/models/Logbook_model.php +++ b/application/models/Logbook_model.php @@ -1639,6 +1639,74 @@ function call_lookup_result($callsign) return $data; } + /** + * Get recent callsign details for a user in a single query and derive the + * latest non-empty value per field in PHP. + */ + function get_recent_callsign_details($callsign, $user_id, $limit = 100) + { + $details = array( + 'name' => '', + 'gridsquare' => '', + 'qth' => '', + 'iota' => '', + 'qsl_via' => '', + 'state' => '', + 'us_county' => null, + ); + + $this->db->select('COL_NAME, COL_GRIDSQUARE, COL_QTH, COL_IOTA, COL_QSL_VIA, COL_STATE, COL_CNTY, COL_TIME_ON'); + $this->db->join('station_profile', 'station_profile.station_id = ' . $this->config->item('table_name') . '.station_id'); + $this->db->where('COL_CALL', $callsign); + $this->db->where('station_profile.user_id', $user_id); + $this->db->order_by('COL_TIME_ON', 'desc'); + $this->db->limit($limit); + $query = $this->db->get($this->config->item('table_name')); + + if ($query->num_rows() === 0) { + return $details; + } + + foreach ($query->result() as $row) { + if ($details['name'] === '' && !empty($row->COL_NAME)) { + $details['name'] = $row->COL_NAME; + } + if ($details['gridsquare'] === '' && !empty($row->COL_GRIDSQUARE)) { + $details['gridsquare'] = strtoupper($row->COL_GRIDSQUARE); + } + if ($details['qth'] === '' && !empty($row->COL_QTH)) { + $details['qth'] = $row->COL_QTH; + } + if ($details['iota'] === '' && !empty($row->COL_IOTA)) { + $details['iota'] = $row->COL_IOTA; + } + if ($details['qsl_via'] === '' && !empty($row->COL_QSL_VIA)) { + $details['qsl_via'] = $row->COL_QSL_VIA; + } + if ($details['state'] === '' && !empty($row->COL_STATE)) { + $details['state'] = $row->COL_STATE; + } + if ($details['us_county'] === null && !empty($row->COL_CNTY)) { + $county_parts = explode(',', $row->COL_CNTY, 2); + $details['us_county'] = isset($county_parts[1]) ? trim($county_parts[1]) : trim($county_parts[0]); + } + + if ( + $details['name'] !== '' && + $details['gridsquare'] !== '' && + $details['qth'] !== '' && + $details['iota'] !== '' && + $details['qsl_via'] !== '' && + $details['state'] !== '' && + $details['us_county'] !== null + ) { + break; + } + } + + return $details; + } + /* Callsign QRA */ function call_qra($callsign) { diff --git a/application/models/Logbooks_model.php b/application/models/Logbooks_model.php index 00d832907..78cbd78eb 100644 --- a/application/models/Logbooks_model.php +++ b/application/models/Logbooks_model.php @@ -243,6 +243,42 @@ function public_slug_exists_logbook_id($slug) { } } + function public_slugs_by_user($user_id) { + $clean_user_id = $this->security->xss_clean($user_id); + + $this->db->select('logbook_id, logbook_name, public_slug'); + $this->db->from('station_logbooks'); + $this->db->where('user_id', $clean_user_id); + $this->db->where('public_slug IS NOT NULL', null, false); + $this->db->where("public_slug != ''", null, false); + $this->db->order_by('logbook_name', 'ASC'); + + return $this->db->get(); + } + + function public_slugs_accessible_by_user($user_id) { + $clean_user_id = $this->security->xss_clean($user_id); + + $this->db->select('station_logbooks.logbook_id, station_logbooks.logbook_name, station_logbooks.public_slug, + CASE + WHEN station_logbooks.user_id = '.$this->db->escape($clean_user_id).' THEN "owner" + ELSE slp.permission_level + END as access_level', FALSE); + $this->db->from('station_logbooks'); + $this->db->join('station_logbooks_permissions slp', + 'slp.logbook_id = station_logbooks.logbook_id AND slp.user_id = '.$this->db->escape($clean_user_id), + 'left'); + $this->db->group_start(); + $this->db->where('station_logbooks.user_id', $clean_user_id); + $this->db->or_where('slp.user_id', $clean_user_id); + $this->db->group_end(); + $this->db->where('station_logbooks.public_slug IS NOT NULL', null, false); + $this->db->where("station_logbooks.public_slug != ''", null, false); + $this->db->order_by('station_logbooks.logbook_name', 'ASC'); + + return $this->db->get(); + } + function is_public_slug_available($slug) { // Clean public_slug $clean_slug = $this->security->xss_clean($slug); diff --git a/application/models/Note.php b/application/models/Note.php index 260a589ab..a7ec9742f 100644 --- a/application/models/Note.php +++ b/application/models/Note.php @@ -401,7 +401,7 @@ public function count_public_station_diary_entries($user_id) { return (int)$this->db->count_all_results(); } - public function get_public_station_diary_entries($user_id, $limit = 10, $offset = 0) { + public function get_public_station_diary_entries($user_id, $limit = 10, $offset = 0, $include_qso_list = TRUE) { $this->db->from('notes'); $this->db->where('user_id', (int)$user_id); $this->db->where('cat', 'Station Diary'); @@ -419,6 +419,8 @@ public function get_public_station_diary_entries($user_id, $limit = 10, $offset } $imagesMap = $this->get_diary_images($ids); + $qsoSummaryMemo = array(); + $qsoListMemo = array(); foreach ($entries as $entry) { $entry->images = isset($imagesMap[$entry->id]) ? $imagesMap[$entry->id] : array(); $entry->qso_summary = null; @@ -430,14 +432,37 @@ public function get_public_station_diary_entries($user_id, $limit = 10, $offset $dateStart = !empty($entry->qso_date_start) ? $entry->qso_date_start : $entryDate; $dateEnd = !empty($entry->qso_date_end) ? $entry->qso_date_end : $entryDate; $satOnly = (int)$entry->qso_satellite_only === 1; + $memoKey = implode('|', array( + (int)$user_id, + (string)$dateStart, + (string)$dateEnd, + (string)($entry->logbook_id ?? ''), + $satOnly ? '1' : '0', + )); // Use date range filtering if dates are set, otherwise fall back to single-day filtering if (!empty($entry->qso_date_start) || !empty($entry->qso_date_end)) { - $entry->qso_summary = $this->get_qso_summary_for_date_range($user_id, $dateStart, $dateEnd, $entry->logbook_id, $satOnly); - $entry->qso_list = $this->get_qso_list_for_date_range($user_id, $dateStart, $dateEnd, $entry->logbook_id, $satOnly); + if (!array_key_exists($memoKey, $qsoSummaryMemo)) { + $qsoSummaryMemo[$memoKey] = $this->get_qso_summary_for_date_range($user_id, $dateStart, $dateEnd, $entry->logbook_id, $satOnly); + } + $entry->qso_summary = $qsoSummaryMemo[$memoKey]; + if ($include_qso_list) { + if (!array_key_exists($memoKey, $qsoListMemo)) { + $qsoListMemo[$memoKey] = $this->get_qso_list_for_date_range($user_id, $dateStart, $dateEnd, $entry->logbook_id, $satOnly); + } + $entry->qso_list = $qsoListMemo[$memoKey]; + } } else { - $entry->qso_summary = $this->get_qso_summary_for_date($user_id, $entryDate, $entry->logbook_id); - $entry->qso_list = $this->get_qso_list_for_date($user_id, $entryDate, $entry->logbook_id); + if (!array_key_exists($memoKey, $qsoSummaryMemo)) { + $qsoSummaryMemo[$memoKey] = $this->get_qso_summary_for_date($user_id, $entryDate, $entry->logbook_id); + } + $entry->qso_summary = $qsoSummaryMemo[$memoKey]; + if ($include_qso_list) { + if (!array_key_exists($memoKey, $qsoListMemo)) { + $qsoListMemo[$memoKey] = $this->get_qso_list_for_date($user_id, $entryDate, $entry->logbook_id); + } + $entry->qso_list = $qsoListMemo[$memoKey]; + } } } } @@ -577,9 +602,16 @@ private function get_user_station_ids($user_id) { } private function get_station_ids_for_summary($user_id, $logbook_id = NULL) { + static $memoized_station_ids = array(); + $cacheKey = (int)$user_id . '|' . (string)($logbook_id === NULL ? 'null' : $logbook_id); + if (array_key_exists($cacheKey, $memoized_station_ids)) { + return $memoized_station_ids[$cacheKey]; + } + if ($logbook_id === NULL || $logbook_id === '' || $logbook_id === 0 || $logbook_id === '0') { // No logbook specified, use all user stations - return $this->get_user_station_ids($user_id); + $memoized_station_ids[$cacheKey] = $this->get_user_station_ids($user_id); + return $memoized_station_ids[$cacheKey]; } // Get stations associated with the specified logbook @@ -605,10 +637,121 @@ private function get_station_ids_for_summary($user_id, $logbook_id = NULL) { foreach ($verify_query->result() as $row) { $verified_ids[] = (int)$row->station_id; } - return $verified_ids; + $memoized_station_ids[$cacheKey] = $verified_ids; + return $memoized_station_ids[$cacheKey]; } - return $station_ids; + $memoized_station_ids[$cacheKey] = $station_ids; + return $memoized_station_ids[$cacheKey]; + } + + private function get_day_bounds($date) { + $start = date('Y-m-d 00:00:00', strtotime($date)); + $end = date('Y-m-d 00:00:00', strtotime($date . ' +1 day')); + return array($start, $end); + } + + private function get_date_range_bounds($start_date, $end_date) { + $start = date('Y-m-d 00:00:00', strtotime($start_date)); + $end = date('Y-m-d 00:00:00', strtotime($end_date . ' +1 day')); + return array($start, $end); + } + + private function get_primary_qso_grid($candidate) { + $grid = strtoupper(trim((string)($candidate->COL_GRIDSQUARE ?? ''))); + if ($grid !== '') { + return $grid; + } + + $vuccGrids = strtoupper(trim((string)($candidate->COL_VUCC_GRIDS ?? ''))); + if ($vuccGrids === '') { + return ''; + } + + $parts = explode(',', $vuccGrids); + foreach ($parts as $part) { + $part = trim($part); + if ($part !== '') { + return $part; + } + } + + return ''; + } + + private function get_dxcc_coordinates_for_candidate($candidate, &$dxccLookupCache) { + $dxccLat = $candidate->dxcc_lat ?? null; + $dxccLong = $candidate->dxcc_long ?? null; + + if (is_numeric($dxccLat) && is_numeric($dxccLong)) { + return array((float)$dxccLat, (float)$dxccLong); + } + + $call = strtoupper(trim((string)($candidate->COL_CALL ?? ''))); + if ($call === '') { + return array(null, null); + } + + $date = date('Ymd'); + if (!empty($candidate->COL_TIME_ON)) { + $timestamp = strtotime($candidate->COL_TIME_ON); + if ($timestamp !== FALSE) { + $date = date('Ymd', $timestamp); + } + } + + $cacheKey = $call . '|' . $date; + if (!array_key_exists($cacheKey, $dxccLookupCache)) { + $this->load->model('logbook_model'); + $lookup = $this->logbook_model->dxcc_lookup($call, $date); + if (is_array($lookup) && isset($lookup['lat'], $lookup['long']) && is_numeric($lookup['lat']) && is_numeric($lookup['long'])) { + $dxccLookupCache[$cacheKey] = array((float)$lookup['lat'], (float)$lookup['long']); + } else { + $dxccLookupCache[$cacheKey] = array(null, null); + } + } + + return $dxccLookupCache[$cacheKey]; + } + + private function resolve_highlight_candidate_distance_km($candidate, &$dxccLookupCache) { + $stationGrid = trim((string)($candidate->station_gridsquare ?? '')); + + if ($stationGrid !== '') { + $workedGrid = $this->get_primary_qso_grid($candidate); + if ($workedGrid !== '') { + return (float)$this->qra->distance($stationGrid, $workedGrid, 'K'); + } + + list($dxccLat, $dxccLong) = $this->get_dxcc_coordinates_for_candidate($candidate, $dxccLookupCache); + if ($dxccLat !== null && $dxccLong !== null) { + $stationCoords = $this->qra->qra2latlong($stationGrid); + if (is_array($stationCoords) && isset($stationCoords[0], $stationCoords[1]) && is_numeric($stationCoords[0]) && is_numeric($stationCoords[1])) { + return (float)distance((float)$stationCoords[0], (float)$stationCoords[1], $dxccLat, $dxccLong, 'K'); + } + } + } + + return (float)($candidate->COL_DISTANCE ?? 0); + } + + private function build_highlight_dx_from_candidates($highlight_candidates) { + $highlight = null; + if (!empty($highlight_candidates)) { + $this->load->library('Qra'); + $best_distance = 0; + $dxccLookupCache = array(); + foreach ($highlight_candidates as $candidate) { + $dist = $this->resolve_highlight_candidate_distance_km($candidate, $dxccLookupCache); + if ($dist > $best_distance) { + $best_distance = $dist; + $highlight = $candidate; + $highlight->COL_DISTANCE = (int)round($dist); + } + } + } + + return $highlight; } public function get_qso_list_for_date($user_id, $date, $logbook_id = NULL) { @@ -618,11 +761,13 @@ public function get_qso_list_for_date($user_id, $date, $logbook_id = NULL) { } $table = $this->config->item('table_name'); + list($dayStart, $dayEnd) = $this->get_day_bounds($date); $this->db->select('COL_CALL, COL_TIME_ON, COL_BAND, COL_MODE, COL_SUBMODE, COL_COUNTRY, COL_GRIDSQUARE, COL_VUCC_GRIDS, COL_RST_SENT, COL_RST_RCVD, COL_FREQ, COL_DXCC, COL_DISTANCE, COL_PROP_MODE, COL_SAT_NAME, COL_SAT_MODE'); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON)', $date); + $this->db->where('COL_TIME_ON >=', $dayStart); + $this->db->where('COL_TIME_ON <', $dayEnd); $this->db->order_by('COL_TIME_ON', 'ASC'); $this->db->limit(100); // Reasonable limit for public display @@ -637,18 +782,21 @@ public function get_qso_summary_for_date($user_id, $date, $logbook_id = NULL) { } $table = $this->config->item('table_name'); + list($dayStart, $dayEnd) = $this->get_day_bounds($date); $this->db->select('COUNT(*) AS total_qsos, COUNT(DISTINCT COL_DXCC) AS dxcc_worked'); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON)', $date); + $this->db->where('COL_TIME_ON >=', $dayStart); + $this->db->where('COL_TIME_ON <', $dayEnd); $overview = $this->db->get()->row(); $this->db->distinct(); $this->db->select('LOWER(COL_BAND) AS band, COL_BAND+0 AS band_num', FALSE); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON)', $date); + $this->db->where('COL_TIME_ON >=', $dayStart); + $this->db->where('COL_TIME_ON <', $dayEnd); $this->db->where('COL_BAND IS NOT NULL', null, FALSE); $this->db->where('COL_BAND !=', ''); $this->db->order_by('band_num', 'ASC'); @@ -658,19 +806,22 @@ public function get_qso_summary_for_date($user_id, $date, $logbook_id = NULL) { $this->db->select('(CASE WHEN COL_SUBMODE IS NOT NULL AND COL_SUBMODE != "" THEN UPPER(COL_SUBMODE) ELSE UPPER(COL_MODE) END) AS mode_label', FALSE); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON)', $date); + $this->db->where('COL_TIME_ON >=', $dayStart); + $this->db->where('COL_TIME_ON <', $dayEnd); $this->db->where('COL_MODE IS NOT NULL', null, FALSE); $this->db->where('COL_MODE !=', ''); $this->db->order_by('mode_label', 'ASC'); $modesResult = $this->db->get()->result(); - // Get highlight DX - prefer stored distance, but also consider QSOs with a - // gridsquare (or VUCC grids) that have no stored distance so they are not excluded. - $this->db->select('t.COL_CALL, t.COL_COUNTRY, t.COL_DISTANCE, t.COL_GRIDSQUARE, t.COL_VUCC_GRIDS, sp.station_gridsquare', FALSE); + // Get highlight DX candidates. Distance is re-calculated in PHP using grid first, + // and a DXCC location fallback when worked grid is missing. + $this->db->select('t.COL_CALL, t.COL_COUNTRY, t.COL_TIME_ON, t.COL_DXCC, t.COL_DISTANCE, t.COL_GRIDSQUARE, t.COL_VUCC_GRIDS, sp.station_gridsquare, de.lat AS dxcc_lat, de.`long` AS dxcc_long', FALSE); $this->db->from($table . ' t'); $this->db->join('station_profile sp', 'sp.station_id = t.station_id', 'left'); + $this->db->join('dxcc_entities de', 'de.adif = t.COL_DXCC', 'left'); $this->db->where_in('t.station_id', $station_ids); - $this->db->where('DATE(t.COL_TIME_ON)', $date); + $this->db->where('t.COL_TIME_ON >=', $dayStart); + $this->db->where('t.COL_TIME_ON <', $dayEnd); $this->db->group_start(); $this->db->where('t.COL_DISTANCE >', 0); $this->db->or_group_start(); @@ -690,25 +841,7 @@ public function get_qso_summary_for_date($user_id, $date, $logbook_id = NULL) { $this->db->limit(50); $highlight_candidates = $this->db->get()->result(); - $highlight = null; - if (!empty($highlight_candidates)) { - $this->load->library('Qra'); - $best_distance = 0; - foreach ($highlight_candidates as $candidate) { - $dist = (float)($candidate->COL_DISTANCE ?? 0); - if ($dist <= 0 && !empty($candidate->station_gridsquare)) { - $grid = !empty($candidate->COL_GRIDSQUARE) ? $candidate->COL_GRIDSQUARE : ($candidate->COL_VUCC_GRIDS ?? ''); - if (!empty($grid)) { - $dist = (float)$this->qra->distance($candidate->station_gridsquare, $grid, 'K'); - } - } - if ($dist > $best_distance) { - $best_distance = $dist; - $highlight = $candidate; - $highlight->COL_DISTANCE = (int)round($dist); - } - } - } + $highlight = $this->build_highlight_dx_from_candidates($highlight_candidates); $bands = array(); foreach ($bandsResult as $band) { @@ -798,12 +931,13 @@ public function get_qso_list_for_date_range($user_id, $start_date, $end_date, $l } $table = $this->config->item('table_name'); + list($rangeStart, $rangeEnd) = $this->get_date_range_bounds($start_date, $end_date); $this->db->select('COL_CALL, COL_TIME_ON, COL_BAND, COL_MODE, COL_SUBMODE, COL_COUNTRY, COL_GRIDSQUARE, COL_VUCC_GRIDS, COL_RST_SENT, COL_RST_RCVD, COL_FREQ, COL_DXCC, COL_DISTANCE, COL_PROP_MODE, COL_SAT_NAME, COL_SAT_MODE'); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON) >=', $start_date); - $this->db->where('DATE(COL_TIME_ON) <=', $end_date); + $this->db->where('COL_TIME_ON >=', $rangeStart); + $this->db->where('COL_TIME_ON <', $rangeEnd); if ($sat_only) { $this->db->where('COL_PROP_MODE', 'SAT'); @@ -823,12 +957,13 @@ public function get_qso_summary_for_date_range($user_id, $start_date, $end_date, } $table = $this->config->item('table_name'); + list($rangeStart, $rangeEnd) = $this->get_date_range_bounds($start_date, $end_date); $this->db->select('COUNT(*) AS total_qsos, COUNT(DISTINCT COL_DXCC) AS dxcc_worked'); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON) >=', $start_date); - $this->db->where('DATE(COL_TIME_ON) <=', $end_date); + $this->db->where('COL_TIME_ON >=', $rangeStart); + $this->db->where('COL_TIME_ON <', $rangeEnd); if ($sat_only) { $this->db->where('COL_PROP_MODE', 'SAT'); @@ -841,8 +976,8 @@ public function get_qso_summary_for_date_range($user_id, $start_date, $end_date, $this->db->select('LOWER(COL_BAND) AS band, COL_BAND+0 AS band_num', FALSE); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON) >=', $start_date); - $this->db->where('DATE(COL_TIME_ON) <=', $end_date); + $this->db->where('COL_TIME_ON >=', $rangeStart); + $this->db->where('COL_TIME_ON <', $rangeEnd); $this->db->where('COL_BAND IS NOT NULL', null, FALSE); $this->db->where('COL_BAND !=', ''); @@ -858,8 +993,8 @@ public function get_qso_summary_for_date_range($user_id, $start_date, $end_date, $this->db->select('(CASE WHEN COL_SUBMODE IS NOT NULL AND COL_SUBMODE != "" THEN UPPER(COL_SUBMODE) ELSE UPPER(COL_MODE) END) AS mode_label', FALSE); $this->db->from($table); $this->db->where_in('station_id', $station_ids); - $this->db->where('DATE(COL_TIME_ON) >=', $start_date); - $this->db->where('DATE(COL_TIME_ON) <=', $end_date); + $this->db->where('COL_TIME_ON >=', $rangeStart); + $this->db->where('COL_TIME_ON <', $rangeEnd); $this->db->where('COL_MODE IS NOT NULL', null, FALSE); $this->db->where('COL_MODE !=', ''); @@ -870,14 +1005,15 @@ public function get_qso_summary_for_date_range($user_id, $start_date, $end_date, $this->db->order_by('mode_label', 'ASC'); $modesResult = $this->db->get()->result(); - // Get highlight DX - prefer stored distance, but also consider QSOs with a - // gridsquare (or VUCC grids) that have no stored distance so they are not excluded. - $this->db->select('t.COL_CALL, t.COL_COUNTRY, t.COL_DISTANCE, t.COL_GRIDSQUARE, t.COL_VUCC_GRIDS, sp.station_gridsquare', FALSE); + // Get highlight DX candidates. Distance is re-calculated in PHP using grid first, + // and a DXCC location fallback when worked grid is missing. + $this->db->select('t.COL_CALL, t.COL_COUNTRY, t.COL_TIME_ON, t.COL_DXCC, t.COL_DISTANCE, t.COL_GRIDSQUARE, t.COL_VUCC_GRIDS, sp.station_gridsquare, de.lat AS dxcc_lat, de.`long` AS dxcc_long', FALSE); $this->db->from($table . ' t'); $this->db->join('station_profile sp', 'sp.station_id = t.station_id', 'left'); + $this->db->join('dxcc_entities de', 'de.adif = t.COL_DXCC', 'left'); $this->db->where_in('t.station_id', $station_ids); - $this->db->where('DATE(t.COL_TIME_ON) >=', $start_date); - $this->db->where('DATE(t.COL_TIME_ON) <=', $end_date); + $this->db->where('t.COL_TIME_ON >=', $rangeStart); + $this->db->where('t.COL_TIME_ON <', $rangeEnd); if ($sat_only) { $this->db->where('t.COL_PROP_MODE', 'SAT'); @@ -902,25 +1038,7 @@ public function get_qso_summary_for_date_range($user_id, $start_date, $end_date, $this->db->limit(50); $highlight_candidates = $this->db->get()->result(); - $highlight = null; - if (!empty($highlight_candidates)) { - $this->load->library('Qra'); - $best_distance = 0; - foreach ($highlight_candidates as $candidate) { - $dist = (float)($candidate->COL_DISTANCE ?? 0); - if ($dist <= 0 && !empty($candidate->station_gridsquare)) { - $grid = !empty($candidate->COL_GRIDSQUARE) ? $candidate->COL_GRIDSQUARE : ($candidate->COL_VUCC_GRIDS ?? ''); - if (!empty($grid)) { - $dist = (float)$this->qra->distance($candidate->station_gridsquare, $grid, 'K'); - } - } - if ($dist > $best_distance) { - $best_distance = $dist; - $highlight = $candidate; - $highlight->COL_DISTANCE = (int)round($dist); - } - } - } + $highlight = $this->build_highlight_dx_from_candidates($highlight_candidates); $bands = array(); foreach ($bandsResult as $band) { diff --git a/application/models/User_model.php b/application/models/User_model.php index 7b79b877e..731cc3a4e 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -430,6 +430,16 @@ function update_session($id) { $CI =& get_instance(); $CI->load->model('user_options_model'); $callbook_type_object = $CI->user_options_model->get_options('callbook')->result(); + $show_qsl_cards_option = $CI->user_options_model->get_options( + 'menu', + array('option_name' => 'show_qsl_cards', 'option_key' => 'enabled'), + $id + )->row(); + $show_sstv_images_option = $CI->user_options_model->get_options( + 'menu', + array('option_name' => 'show_sstv_images', 'option_key' => 'enabled'), + $id + )->row(); // Get the callbook type if (isset($callbook_type_object[1]->option_value)) { @@ -453,6 +463,15 @@ function update_session($id) { } $u = $this->get_by_id($id); + $has_eqsl_credentials = ($u->row()->user_eqsl_name != '' && $u->row()->user_eqsl_password != ''); + $show_qsl_cards = true; + if (isset($show_qsl_cards_option->option_value)) { + $show_qsl_cards = ($show_qsl_cards_option->option_value == 'true'); + } + $show_sstv_images = false; + if (isset($show_sstv_images_option->option_value)) { + $show_sstv_images = ($show_sstv_images_option->option_value == 'true'); + } $userdata = array( 'user_id' => $u->row()->user_id, @@ -464,6 +483,9 @@ function update_session($id) { 'user_lotw_name' => $u->row()->user_lotw_name, 'user_eqsl_name' => $u->row()->user_eqsl_name, 'user_eqsl_qth_nickname' => $u->row()->user_eqsl_qth_nickname, + 'has_eqsl_credentials' => $has_eqsl_credentials, + 'user_show_qsl_cards' => $show_qsl_cards, + 'user_show_sstv_images' => $show_sstv_images, 'user_hash' => $this->_hash($u->row()->user_id."-".$u->row()->user_type), 'radio' => isset($_COOKIE["radio"])?$_COOKIE["radio"]:"", 'station_profile_id' => isset($_COOKIE["station_profile_id"])?$_COOKIE["station_profile_id"]:"", diff --git a/application/views/interface_assets/footer.php b/application/views/interface_assets/footer.php index ff16237eb..9a88042ca 100644 --- a/application/views/interface_assets/footer.php +++ b/application/views/interface_assets/footer.php @@ -2093,6 +2093,10 @@ function getUTCDateStamp(el) { \ No newline at end of file diff --git a/application/views/version_dialog/index.php b/application/views/version_dialog/index.php index 03981fa3a..1103694ee 100644 --- a/application/views/version_dialog/index.php +++ b/application/views/version_dialog/index.php @@ -90,8 +90,116 @@ optionslib) ? $this->optionslib->get_option('version_dialog_text') : null; if ($versionDialogText !== null) { - $versionDialogTextWithLinks = preg_replace('/(https?:\/\/[^\s<]+)/', '$1', $versionDialogText); - echo nl2br($versionDialogTextWithLinks); + // Apply markdown conversion to custom text + $htmlContent = $versionDialogText; + + // Convert inline code (`) - protect inline code from other conversions + $htmlContent = preg_replace('/`([^`]+)`/', '$1', $htmlContent); + + // Convert markdown horizontal rules (---, ***, ___) - split by line for robustness + $lines = preg_split('/\r?\n/', $htmlContent); + $processedLines = array(); + foreach ($lines as $line) { + $trimmed = trim($line); + // Check if line is ONLY HR markers (3+ of same character, optionally with spaces) + if (preg_match('/^[ \t]{0,3}([\*\-_])\1{2,}[ \t]*$/', $trimmed)) { + $processedLines[] = '
'; + } else { + $processedLines[] = $line; + } + } + $htmlContent = implode("\n", $processedLines); + + // Convert blockquotes (>) before lists + $htmlContent = preg_replace_callback( + '/((?:^>[ \t]?.+$(?:\r?\n)?)+)/m', + function($matches) { + $blockquoteContent = $matches[1]; + $blockquoteContent = preg_replace('/^>[ \t]?/m', '', $blockquoteContent); + return "\n
\n" . $blockquoteContent . "
\n"; + }, + $htmlContent + ); + + // Convert strikethrough (~~text~~) + $htmlContent = preg_replace('/~~(.+?)~~/s', '$1', $htmlContent); + + // Convert bullet points to list items + $htmlContent = preg_replace_callback( + '/((?:^[ \t]*[\*\-\+] .+$(?:\r?\n)?)+)/m', + function($matches) { + $listContent = $matches[1]; + // Handle task lists first + $listContent = preg_replace_callback( + '/^[ \t]*[\*\-\+] \[[ xX]\]/m', + function($m) { + $isChecked = strpos($m[0], '[x') !== false || strpos($m[0], '[X') !== false; + return ' '; + }, + $listContent + ); + $listContent = preg_replace('/^[ \t]*[\*\-\+] (.+?)[ \t]*$/m', '
  • $1
  • ', $listContent); + $listContent = preg_replace('/(<\/li>)\r?\n(?=
  • )/', '$1', $listContent); + return "\n\n"; + }, + $htmlContent + ); + + // Convert numbered lists + $htmlContent = preg_replace_callback( + '/((?:^[ \t]*\d+\. .+$(?:\r?\n)?)+)/m', + function($matches) { + $listContent = $matches[1]; + $listContent = preg_replace('/^[ \t]*\d+\. (.+?)[ \t]*$/m', '
  • $1
  • ', $listContent); + $listContent = preg_replace('/(<\/li>)\r?\n(?=
  • )/', '$1', $listContent); + return "\n
      \n" . $listContent . "
    \n"; + }, + $htmlContent + ); + + // Convert headers + $htmlContent = preg_replace('/^#### (.+)$/m', '
    $1
    ', $htmlContent); + $htmlContent = preg_replace('/^### (.+)$/m', '

    $1

    ', $htmlContent); + $htmlContent = preg_replace('/^## (.+)$/m', '

    $1

    ', $htmlContent); + $htmlContent = preg_replace('/^# (.+)$/m', '

    $1

    ', $htmlContent); + + // Convert bold and italic + $htmlContent = preg_replace('/\*\*(.+?)\*\*/', '$1', $htmlContent); + $htmlContent = preg_replace('/__(.+?)__/', '$1', $htmlContent); + $htmlContent = preg_replace('/(?$1', $htmlContent); + $htmlContent = preg_replace('/(?$1', $htmlContent); + + // Convert images and links + $htmlContent = preg_replace('/!\[([^\]]*)\]\(([^)]+)\)/', '$1', $htmlContent); + $htmlContent = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '$1', $htmlContent); + + // Convert GitHub usernames and URLs + $htmlContent = preg_replace('/(?@$1', $htmlContent); + $htmlContent = preg_replace('/(?|>\s)(https?:\/\/[^\s<]+)/', '$1', $htmlContent); + + // Protect block elements from nl2br + $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre|blockquote)>)\r?\n/', '$1', $htmlContent); + $htmlContent = preg_replace('/(<(?:ul|ol|pre|blockquote)>)\r?\n/', '$1', $htmlContent); + $htmlContent = preg_replace('/(\r?\n)/', '$1', $htmlContent); + + // Convert line breaks + $htmlContent = nl2br($htmlContent); + + // Remove block markers + $htmlContent = preg_replace('//', '', $htmlContent); + $htmlContent = preg_replace('//', '', $htmlContent); + $htmlContent = preg_replace('//', '', $htmlContent); + + // Cleanup extra br tags around block elements + // Remove ALL consecutive br tags before block elements + $htmlContent = preg_replace('/(?:\s*)+(<(?:h[1-6]|ul|ol|li|pre|code|hr|blockquote)[^>]*>)/i', '$1', $htmlContent); + $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre|blockquote)>|\s*)\s*(?:\s*)+/i', '$1', $htmlContent); + $htmlContent = preg_replace('/(){3,}/', '

    ', $htmlContent); + + // Remove br tags inside list items + $htmlContent = preg_replace('/(
  • [^<]*)(\s*<\/li>)/i', '$1$2', $htmlContent); + + echo $htmlContent; } else { echo 'No Version Dialog text set. Go to the Admin Menu and set one.'; } @@ -141,13 +249,54 @@ // Convert inline code (`) - protect inline code from other conversions $htmlContent = preg_replace('/`([^`]+)`/', '$1', $htmlContent); + // Convert markdown horizontal rules (---, ***, ___) - split by line for robustness + $lines = preg_split('/\r?\n/', $htmlContent); + $processedLines = array(); + foreach ($lines as $line) { + $trimmed = trim($line); + // Check if line is ONLY HR markers (3+ of same character, optionally with spaces) + if (preg_match('/^[ \t]{0,3}([\*\-_])\1{2,}[ \t]*$/', $trimmed)) { + $processedLines[] = '
    '; + } else { + $processedLines[] = $line; + } + } + $htmlContent = implode("\n", $processedLines); + + // Convert blockquotes (>) before lists to avoid interference + $htmlContent = preg_replace_callback( + '/((?:^>[ \t]?.+$(?:\r?\n)?)+)/m', + function($matches) { + $blockquoteContent = $matches[1]; + // Remove leading > from each line and trim + $blockquoteContent = preg_replace('/^>[ \t]?/m', '', $blockquoteContent); + // Wrap in
    tags + return "\n
    \n" . $blockquoteContent . "
    \n"; + }, + $htmlContent + ); + + // Convert strikethrough (~~text~~) + $htmlContent = preg_replace('/~~(.+?)~~/s', '$1', $htmlContent); + // Convert bullet points to list items (must be done before bold/italic to avoid interference) $htmlContent = preg_replace_callback( '/((?:^[ \t]*[\*\-\+] .+$(?:\r?\n)?)+)/m', function($matches) { $listContent = $matches[1]; - // Convert each bullet point to
  • - $listContent = preg_replace('/^[ \t]*[\*\-\+] (.+)$/m', '
  • $1
  • ', $listContent); + // Handle task lists first: [ ] or [x] or [X] + $listContent = preg_replace_callback( + '/^[ \t]*[\*\-\+] \[[ xX]\]/m', + function($m) { + $isChecked = strpos($m[0], '[x') !== false || strpos($m[0], '[X') !== false; + return ' '; + }, + $listContent + ); + // Convert each bullet point to
  • , stripping trailing whitespace + $listContent = preg_replace('/^[ \t]*[\*\-\+] (.+?)[ \t]*$/m', '
  • $1
  • ', $listContent); + // Remove newlines between list items to prevent nl2br from adding extra breaks + $listContent = preg_replace('/(<\/li>)\r?\n(?=
  • )/', '$1', $listContent); // Wrap in
      tags return "\n
        \n" . $listContent . "
      \n"; }, @@ -159,8 +308,10 @@ function($matches) { '/((?:^[ \t]*\d+\. .+$(?:\r?\n)?)+)/m', function($matches) { $listContent = $matches[1]; - // Convert each numbered item to
    • - $listContent = preg_replace('/^[ \t]*\d+\. (.+)$/m', '
    • $1
    • ', $listContent); + // Convert each numbered item to
    • , stripping trailing whitespace + $listContent = preg_replace('/^[ \t]*\d+\. (.+?)[ \t]*$/m', '
    • $1
    • ', $listContent); + // Remove newlines between list items to prevent nl2br from adding extra breaks + $listContent = preg_replace('/(<\/li>)\r?\n(?=
    • )/', '$1', $listContent); // Wrap in
        tags return "\n
          \n" . $listContent . "
        \n"; }, @@ -194,8 +345,9 @@ function($matches) { $htmlContent = preg_replace('/(?|>\s)(https?:\/\/[^\s<]+)/', '$1', $htmlContent); // Replace newlines after block-level elements with a marker to prevent nl2br from converting them - $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre)>)\r?\n/', '$1', $htmlContent); - $htmlContent = preg_replace('/(<(?:ul|ol|pre)>)\r?\n/', '$1', $htmlContent); + $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre|blockquote)>)\r?\n/', '$1', $htmlContent); + $htmlContent = preg_replace('/(<(?:ul|ol|pre|blockquote)>)\r?\n/', '$1', $htmlContent); + $htmlContent = preg_replace('/(\r?\n)/', '$1', $htmlContent); // Convert line breaks to
        tags $htmlContent = nl2br($htmlContent); @@ -206,12 +358,14 @@ function($matches) { $htmlContent = preg_replace('//', '', $htmlContent); // Additional cleanup: remove br tags immediately before and after block elements - $htmlContent = preg_replace('/\s*(<(?:h[1-6]|ul|ol|li|pre|code)[^>]*>)/i', '$1', $htmlContent); - $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre)>)\s*/i', '$1', $htmlContent); - - // Remove multiple consecutive br tags (more than 2) + // Remove ALL consecutive br tags before block elements + $htmlContent = preg_replace('/(?:\s*)+(<(?:h[1-6]|ul|ol|li|pre|code|hr|blockquote)[^>]*>)/i', '$1', $htmlContent); + $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre|blockquote)>|\s*)\s*(?:\s*)+/i', '$1', $htmlContent); $htmlContent = preg_replace('/(){3,}/', '

        ', $htmlContent); + // Remove br tags inside list items + $htmlContent = preg_replace('/(
      1. [^<]*)(\s*<\/li>)/i', '$1$2', $htmlContent); + echo "
        " . $htmlContent . "
        "; } else { echo '

        v' . $current_version . '

        '; diff --git a/assets/js/sections/qso.js b/assets/js/sections/qso.js index f850b48b6..a99d6e3b6 100644 --- a/assets/js/sections/qso.js +++ b/assets/js/sections/qso.js @@ -1,5 +1,12 @@ var lastCallsignUpdated="" var callsignLookupRequestId = 0; +var callsignDxccQuickRequestId = 0; +var callsignDxccQuickTimer = null; +var isSubmitting = false; +var lastResetCatSyncNoticeAt = 0; +var suppressNextResetHandler = false; +var previousContactsLookupMode = false; +var htmxAutoRefreshTimer = null; function hasFieldValue(value) { return value !== null && value !== undefined && String(value).trim() !== ""; @@ -9,6 +16,42 @@ function normalizeFieldValue(value) { return String(value ?? "").trim(); } +function escapeNoticeValue(value) { + return String(value || '').replace(/[&<>"']/g, function(char) { + var escapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return escapes[char] || char; + }); +} + +function showQsoNotice(message, alertType) { + var safeType = alertType || 'info'; + var $container = $('#notice-alerts-container'); + if ($container.length === 0) { + $container = $('
        '); + var $rightColumn = $('.col-sm-7').first(); + var $mapCard = $rightColumn.find('.qso-map').first(); + if ($mapCard.length > 0) { + $container.insertBefore($mapCard); + } else if ($rightColumn.length > 0) { + $rightColumn.prepend($container); + } + } + + $container.html(''); + + setTimeout(function() { + $('#notice-alerts').fadeOut(300, function() { + $(this).remove(); + }); + }, 5000); +} + function shouldReplaceLookupField($field, incomingValue, fieldKey, approval) { if (!hasFieldValue(incomingValue)) { return false; @@ -541,11 +584,11 @@ var favs={}; }); // Test Consistency value on submit form // - var isSubmitting = false; $("#qso_input").off('submit').on('submit', function(e){ + e.preventDefault(); + // Prevent double submission if (isSubmitting) { - e.preventDefault(); return false; } @@ -557,6 +600,10 @@ var favs={}; if (_submit) { // Mark as submitting and disable the submit button isSubmitting = true; + $('#qso_input .warningOnSubmit').hide(); + $('#qso_input .warningOnSubmit_txt').empty(); + + var $form = $(this); var submitBtn = $(this).find('button[type="submit"]'); var originalText = submitBtn.data('original-text'); if (!originalText) { @@ -566,9 +613,88 @@ var favs={}; } submitBtn.prop('disabled', true); submitBtn.html(' Saving...'); + + var ajaxSaveUrl = $form.data('ajax-save-url') || (base_url + 'index.php/qso/ajax_saveqso'); + + $.ajax({ + url: ajaxSaveUrl, + type: 'POST', + data: $form.serialize(), + dataType: 'json', + success: function(response) { + if (response && response.status === 'ok') { + var savedCallsign = normalizeFieldValue($('#callsign').val()).toUpperCase(); + var savedBand = normalizeFieldValue($('#band').val()); + var savedMode = normalizeFieldValue($('#mode').val()); + var savedSatName = normalizeFieldValue($('#sat_name').val()); + var savedSatMode = normalizeFieldValue($('#sat_mode').val()); + var postSaveDefaults = { + band: savedBand, + mode: savedMode, + sat_name: savedSatName, + sat_mode: savedSatMode + }; + var saveMessage = (response && response.message) ? response.message : 'QSO Added'; + if (savedCallsign && savedBand) { + saveMessage += ': ' + escapeNoticeValue(savedCallsign) + ' on ' + escapeNoticeValue(savedBand) + ''; + } else if (savedCallsign) { + saveMessage += ': ' + escapeNoticeValue(savedCallsign) + ''; + } else if (savedBand) { + saveMessage += ': on ' + escapeNoticeValue(savedBand) + ''; + } + + var qsoFormElement = document.getElementById('qso_input'); + if (qsoFormElement) { + suppressNextResetHandler = true; + qsoFormElement.reset(); + } + + reset_fields(); + if (document.getElementById('qsp-tab')) { + new bootstrap.Tab(document.getElementById('qsp-tab')).show(); + } + reapplyPostSaveDefaults(postSaveDefaults); + showQsoNotice(saveMessage, 'info'); + + if (typeof htmx !== 'undefined' && document.getElementById('qso-last-table')) { + htmx.ajax('GET', base_url + 'index.php/qso/component_past_contacts', { + target: '#qso-last-table', + swap: 'innerHTML' + }); + } + + $('#callsign').focus(); + $('#qso_input').data('initialForm', $('#qso_input').serialize()); + } else { + var warningMessage = (response && response.message) ? response.message : 'Unable to save QSO. Please try again.'; + + if (response && response.validation_errors) { + var validationMessages = []; + $.each(response.validation_errors, function(_, msg) { + if (msg) { + validationMessages.push(msg); + } + }); + if (validationMessages.length > 0) { + warningMessage = validationMessages.join('
        '); + } + } + + $('#qso_input .warningOnSubmit_txt').html(warningMessage); + $('#qso_input .warningOnSubmit').show(); + } + }, + error: function() { + $('#qso_input .warningOnSubmit_txt').html('Unable to save QSO due to a network or server error.'); + $('#qso_input .warningOnSubmit').show(); + }, + complete: function() { + resetSubmissionState(); + } + }); } - return _submit; + return false; }) // Prevent Enter key from causing double submissions @@ -730,10 +856,47 @@ function resetSubmissionState() { } } +function resetCallsignLookupState() { + lastCallsignUpdated = ''; + // Invalidate in-flight responses from older callsign lookups. + callsignLookupRequestId++; + callsignDxccQuickRequestId++; + if (callsignDxccQuickTimer) { + clearTimeout(callsignDxccQuickTimer); + callsignDxccQuickTimer = null; + } +} + +function isLookupStillCurrent(requestId, find_callsign) { + if (requestId !== callsignLookupRequestId) { + return false; + } + + var currentCallsign = $('#callsign').val().toUpperCase().replace(/\//g, "-").replace('Ø', '0'); + return currentCallsign === find_callsign; +} + +function clearSatelliteFields() { + $('#sat_name').val(''); + $('#sat_mode').val(''); + $('.satellite_modes_list').find('option').remove().end(); + selected_sat = ''; + selected_sat_mode = ''; + + if ($('#selectPropagation').val() === 'SAT') { + $('#selectPropagation').val(''); + } +} + +function clearCatTrackedFieldState() { + $('#frequency, #frequency_rx, #sat_name, #sat_mode, #transmit_power, #selectPropagation, #mode').removeData('catValue'); +} + /* Function: reset_fields is used to reset the fields on the QSO page */ function reset_fields() { // Reset submission state resetSubmissionState(); + resetCallsignLookupState(); $('#locator_info').text(""); $('#country').val(""); @@ -745,13 +908,17 @@ function reset_fields() { $('#qrz_info').text(""); $('#hamqth_info').text(""); $('#sota_info').text(""); + $('#wwff_info').html('').attr('title', ''); + $('#pota_info').html('').attr('title', ''); $('#dxcc_id').val("").trigger('change'); $('#cqz').val(""); $('#name').val(""); $('#qth').val(""); $('#locator').val(""); $('#iota_ref').val(""); - $('#sota_ref').val(""); + $select = $('#sota_ref').selectize(); + selectize = $select[0] ? $select[0].selectize : null; + if (selectize) selectize.clear(); $("#locator").removeClass("confirmedGrid"); $("#locator").removeClass("workedGrid"); $("#locator").removeClass("newGrid"); @@ -767,7 +934,10 @@ function reset_fields() { $('#callsign_info').text(""); $('#input_usa_state').val(""); $('#qso-last-table').show(); + $('#partial_view').html(''); $('#partial_view').hide(); + previousContactsLookupMode = false; + resumeAutoRefresh(); var $select = $('#wwff_ref').selectize(); var selectize = $select[0] ? $select[0].selectize : null; if (selectize) selectize.clear(); @@ -781,12 +951,46 @@ function reset_fields() { selectize = $select[0] ? $select[0].selectize : null; if (selectize) selectize.clear(); + clearSatelliteFields(); + clearCatTrackedFieldState(); + mymap.setView(pos, 12); mymap.removeLayer(markers); $('.callsign-suggest').hide(); $('.dxccsummary').remove(); $('#timesWorked').html(lang_qso_title_previous_contacts); renderQsoCallhistoryPanel([], 'Type a callsign to see membership details from your uploaded call history files.'); + + // Reapply default RST values for the current mode (e.g., CW => 599). + if (typeof setRst === 'function') { + setRst($('.mode').val()); + } +} + +function reapplyPostSaveDefaults(defaults) { + if (!defaults) { + return; + } + + if (typeof defaults.band !== 'undefined') { + $('#band').val(defaults.band); + } + + if (typeof defaults.mode !== 'undefined') { + $('#mode').val(defaults.mode); + } + + if (typeof defaults.sat_name !== 'undefined') { + $('#sat_name').val(defaults.sat_name); + } + + if (typeof defaults.sat_mode !== 'undefined') { + $('#sat_mode').val(defaults.sat_mode); + } + + if (typeof setRst === 'function') { + setRst($('#mode').val()); + } } function resetTimers(manual) { @@ -830,7 +1034,7 @@ $("#callsign").focusout(function() { // Replace / in a callsign with - to stop urls breaking $.getJSON(base_url + 'index.php/logbook/json/' + find_callsign + '/' + sat_type + '/' + json_band + '/' + json_mode + '/' + $('#stationProfile').val(), function(result) { - if (requestId !== callsignLookupRequestId) { + if (!isLookupStillCurrent(requestId, find_callsign)) { return; } @@ -838,63 +1042,37 @@ $("#callsign").focusout(function() { var currentCallsign = $('#callsign').val().toUpperCase().replace(/\//g, "-").replace('Ø', '0'); if(currentCallsign === find_callsign) { - // Reset QSO fields - resetDefaultQSOFields(); - - if(result.dxcc.entity != undefined) { - $('#country').val(convert_case(result.dxcc.entity)); - $('#callsign_info').text(convert_case(result.dxcc.entity)); - - if($("#sat_name" ).val() != "") { - //logbook/jsonlookupgrid/io77/SAT/0/0 - $.getJSON(base_url + 'index.php/logbook/jsonlookupcallsign/' + find_callsign + '/SAT/0/0', function(result) - { - // Reset CSS values before updating - $('#callsign').removeClass("workedGrid"); - $('#callsign').removeClass("confirmedGrid"); - $('#callsign').removeClass("newGrid"); - $('#callsign').attr('title', ''); - - if (result.confirmed) { - $('#callsign').addClass("confirmedGrid"); - $('#callsign').attr('title', 'Callsign was already worked and confirmed in the past on this band and mode!'); - } else if (result.workedBefore) { - $('#callsign').addClass("workedGrid"); - $('#callsign').attr('title', 'Callsign was already worked in the past on this band and mode!'); - } - else - { - $('#callsign').addClass("newGrid"); - $('#callsign').attr('title', 'New Callsign!'); - } - }) - } else { - $.getJSON(base_url + 'index.php/logbook/jsonlookupcallsign/' + find_callsign + '/0/' + $("#band").val() +'/' + $("#mode").val(), function(result) - { - // Reset CSS values before updating - $('#callsign').removeClass("confirmedGrid"); - $('#callsign').removeClass("workedGrid"); - $('#callsign').removeClass("newGrid"); - $('#callsign').attr('title', ''); - - if (result.confirmed) { - $('#callsign').addClass("confirmedGrid"); - $('#callsign').attr('title', 'Callsign was already worked and confirmed in the past on this band and mode!'); - } else if (result.workedBefore) { - $('#callsign').addClass("workedGrid"); - $('#callsign').attr('title', 'Callsign was already worked in the past on this band and mode!'); - } else { - $('#callsign').addClass("newGrid"); - $('#callsign').attr('title', 'New Callsign!'); - } - - }) - } - - changebadge(result.dxcc.entity); - + // Enter lookup mode - pause auto-refresh of logbook pagination + previousContactsLookupMode = true; + pauseAutoRefresh(); + + // Reset QSO fields but keep current DXCC badge/country to avoid flicker. + resetDefaultQSOFields(true); + + if(result.dxcc.entity != undefined) { + $('#country').val(convert_case(result.dxcc.entity)); + $('#callsign_info').text(convert_case(result.dxcc.entity)); + + // Reset CSS values before updating + $('#callsign').removeClass("confirmedGrid"); + $('#callsign').removeClass("workedGrid"); + $('#callsign').removeClass("newGrid"); + $('#callsign').attr('title', ''); + + if (result.callsignConfirmed || result.confirmed) { + $('#callsign').addClass("confirmedGrid"); + $('#callsign').attr('title', 'Callsign was already worked and confirmed in the past on this band and mode!'); + } else if (result.callsignWorkedBefore) { + $('#callsign').addClass("workedGrid"); + $('#callsign').attr('title', 'Callsign was already worked in the past on this band and mode!'); + } else { + $('#callsign').addClass("newGrid"); + $('#callsign').attr('title', 'New Callsign!'); } + changebadge(result.dxcc.entity); + } + if(result.lotw_member == "active") { $('#lotw_info').text("LoTW"); if (result.lotw_days > 365) { @@ -920,6 +1098,9 @@ $("#callsign").focusout(function() { var dok_selectize = $dok_select[0] ? $dok_select[0].selectize : null; if (result.dxcc.adif == '230') { $.get(base_url + 'index.php/lookup/dok/' + $('#callsign').val().toUpperCase(), function(result) { + if (!isLookupStillCurrent(requestId, find_callsign)) { + return; + } if (result && dok_selectize) { dok_selectize.addOption({name: result}); dok_selectize.setValue(result, false); @@ -929,7 +1110,7 @@ $("#callsign").focusout(function() { if (dok_selectize) dok_selectize.clear(); } - $('#dxcc_id').val(result.dxcc.adif).trigger('change'); + $('#dxcc_id').val(result.dxcc.adif); $('#cqz').val(result.dxcc.cqz); $('#ituz').val(result.dxcc.ituz); @@ -956,7 +1137,7 @@ $("#callsign").focusout(function() { var overwriteConflicts = getLookupOverwriteConflicts(result); showLookupOverwriteModal(overwriteConflicts).then(function(approval) { - if (requestId !== callsignLookupRequestId) { + if (!isLookupStillCurrent(requestId, find_callsign)) { return; } @@ -1013,12 +1194,22 @@ $("#callsign").focusout(function() { if($('#iota_ref').val() == "") { $('#iota_ref').val(result.callsign_iota); } - // Hide the last QSO table - $('#qso-last-table').hide(); - $('#partial_view').show(); /* display past QSOs */ - $('#partial_view').html(result.partial); - + var partialHtml = (typeof result.partial === 'string') ? result.partial : ''; + + if (partialHtml.trim() !== '') { + // State 2: Callsign found in log with past QSOs + $('#partial_view').html(partialHtml); + setPreviousContactsPanelState(true); + } else { + // State 3: Callsign found but NO past QSOs in database + var noQsoMsg = '
        ' + + '' + + 'No past QSOs with ' + escapeNoticeValue(find_callsign) + '—see callbook details above.' + + '
        '; + $('#partial_view').html(noQsoMsg); + setPreviousContactsPanelState(true); + } // Get DXX Summary getDxccResult(result.dxcc.adif, convert_case(result.dxcc.entity)); } @@ -1031,6 +1222,43 @@ $("#callsign").focusout(function() { } }) +function pauseAutoRefresh() { + if (typeof htmx !== 'undefined' && document.getElementById('qso-last-table-content')) { + var elem = document.getElementById('qso-last-table-content'); + if (elem) { + elem.removeAttribute('hx-trigger'); + } + } +} + +function resumeAutoRefresh() { + if (typeof htmx !== 'undefined' && document.getElementById('qso-last-table-content')) { + var elem = document.getElementById('qso-last-table-content'); + if (elem) { + elem.setAttribute('hx-trigger', 'every 5s'); + htmx.process(elem); + } + } +} + +function setPreviousContactsPanelState(showLookupDetails) { + if (showLookupDetails) { + // Show callsign-specific results (either QSOs found or "not found" message) + $('#qso-last-table').hide(); + $('#qso-last-table').next('small').hide(); + $('#partial_view').show(); + previousContactsLookupMode = true; + return; + } + + // Show paginated logbook (no callsign lookup active) + $('#qso-last-table').show(); + $('#qso-last-table').next('small').show(); + $('#partial_view').html(''); + $('#partial_view').hide(); + previousContactsLookupMode = false; +} + // Function to reset back to Previous Contacts tab function resetToPreviousContactsTab() { // Clear DXCC Summary tab content @@ -1040,15 +1268,66 @@ function resetToPreviousContactsTab() { var previousContactsTab = new bootstrap.Tab(document.getElementById('previous-contacts-tab')); previousContactsTab.show(); } - // Show the previous contacts table - $('#qso-last-table').show(); - $('#partial_view').hide(); + // Show the default previous contacts table and hide lookup details. + setPreviousContactsPanelState(false); +} + +// Re-apply visibility state after HTMX updates the previous contacts markup. +if (typeof htmx !== 'undefined' && document.body) { + document.body.addEventListener('htmx:afterSwap', function(evt) { + var detail = evt && evt.detail ? evt.detail : null; + var target = detail && detail.target ? detail.target : null; + if (!target) { + return; + } + + if (target.id !== 'qso-last-table' && target.id !== 'qso-last-table-content') { + return; + } + + // If we're in lookup mode, keep showing the partial_view (callsign-specific results) + // If we're not in lookup mode, show the main pagination table + if (previousContactsLookupMode) { + setPreviousContactsPanelState(true); + } else if ($('#partial_view').html().trim() !== '') { + setPreviousContactsPanelState(true); + } + }); +} + +// If a radio is selected, prefer current CAT values over stale form defaults. +function syncFromSelectedRadioAfterReset() { + var selectedRadioID = String($('select.radios option:selected').val() || '0'); + if (selectedRadioID === '0') { + return false; + } + + if (typeof updateFromCAT === 'function') { + var now = Date.now(); + if (now - lastResetCatSyncNoticeAt > 1000) { + showQsoNotice('Form reset. Syncing live data from selected radio.', 'info'); + lastResetCatSyncNoticeAt = now; + } + updateFromCAT(selectedRadioID); + return true; + } + + return false; } // Reset to Previous Contacts tab when form is reset $('#qso_input').on('reset', function() { + resetCallsignLookupState(); + + if (suppressNextResetHandler) { + suppressNextResetHandler = false; + return; + } + setTimeout(function() { + reset_fields(); resetToPreviousContactsTab(); + syncFromSelectedRadioAfterReset(); }, 100); }); @@ -1058,11 +1337,36 @@ function resetQsoEntryOnEscape() { return; } + // Capture the operating context the user currently has selected BEFORE native + // form reset clobbers it with server-session defaults (last logged QSO values). + var preBand = $('#band').val(); + var preMode = $('#mode').val(); + var preSatName = $('#sat_name').val(); + var preSatMode = $('#sat_mode').val(); + var prePropMode = $('#selectPropagation').val(); + qsoForm.reset(); - lastCallsignUpdated = ''; + resetCallsignLookupState(); resetDefaultQSOFields(); resetToPreviousContactsTab(); $('#callsign').trigger('focus'); + + // The on('reset') handler runs reset_fields() + clearSatelliteFields() in 100ms. + // Re-apply the captured context afterwards so the user stays on the band/mode/ + // satellite they had selected, not the one from the previous QSO session. + setTimeout(function() { + $('#band').val(preBand); + $('#mode').val(preMode); + if (preSatName) { + $('#sat_name').val(preSatName); + $('#sat_mode').val(preSatMode); + $('#selectPropagation').val(prePropMode || 'SAT'); + } + if (typeof setRst === 'function') { + setRst($('#mode').val()); + } + syncFromSelectedRadioAfterReset(); + }, 150); } // Global ESC handling on the QSO page: reset form, return to Previous Contacts, and focus callsign. @@ -1087,7 +1391,7 @@ $(document).off('keydown.qsoEscapeReset').on('keydown.qsoEscapeReset', function( // Also handle when callsign is cleared (empty value entered) $('#callsign').on('input keyup', function() { if ($(this).val() === '' && lastCallsignUpdated !== '') { - lastCallsignUpdated = ''; + resetCallsignLookupState(); resetDefaultQSOFields(); resetToPreviousContactsTab(); } @@ -1392,8 +1696,48 @@ $("#callsign").on("keypress", function(e) { } }); +function quickLookupDxcc(callsign) { + if (!callsign || callsign.length < 3) { + return; + } + + var requestId = ++callsignDxccQuickRequestId; + var find_callsign = callsign.toUpperCase().replace(/\//g, "-").replace('Ø', '0'); + + $.getJSON(base_url + 'index.php/logbook/jsondxcc/' + find_callsign, function(result) { + if (requestId !== callsignDxccQuickRequestId) { + return; + } + + var currentCallsign = $('#callsign').val().toUpperCase().replace(/\//g, "-").replace('Ø', '0'); + if (currentCallsign !== find_callsign) { + return; + } + + if (result.dxcc && result.dxcc.entity !== undefined) { + $('#country').val(convert_case(result.dxcc.entity)); + $('#callsign_info').text(convert_case(result.dxcc.entity)); + changebadge(result.dxcc.entity); + $('#dxcc_id').val(result.dxcc.adif); + $('#cqz').val(result.dxcc.cqz); + $('#ituz').val(result.dxcc.ituz); + } + }); +} + // On Key up check and suggest callsigns $("#callsign").keyup(function() { + if (callsignDxccQuickTimer) { + clearTimeout(callsignDxccQuickTimer); + } + + var currentCall = $(this).val(); + if (currentCall.length >= 3) { + callsignDxccQuickTimer = setTimeout(function() { + quickLookupDxcc(currentCall); + }, 200); + } + if ($(this).val().length >= 3) { $('.callsign-suggest').show(); $callsign = $(this).val().replace('Ø', '0'); @@ -1414,7 +1758,26 @@ $("#callsign").keyup(function() { }); //Reset QSO form Fields function -function resetDefaultQSOFields() { +function resetDefaultQSOFields(preserveDxccState) { + var keepDxcc = preserveDxccState === true; + var preservedCountry = ''; + var preservedCallsignInfoText = ''; + var preservedCallsignInfoTitle = ''; + var preservedCallsignInfoClass = ''; + var preservedDxccId = ''; + var preservedCqz = ''; + var preservedItuz = ''; + + if (keepDxcc) { + preservedCountry = $('#country').val(); + preservedCallsignInfoText = $('#callsign_info').text(); + preservedCallsignInfoTitle = $('#callsign_info').attr('title') || ''; + preservedCallsignInfoClass = $('#callsign_info').attr('class') || ''; + preservedDxccId = $('#dxcc_id').val(); + preservedCqz = $('#cqz').val(); + preservedItuz = $('#ituz').val(); + } + $('#callsign_info').text(""); $('#locator_info').text(""); $('#country').val(""); @@ -1427,6 +1790,7 @@ function resetDefaultQSOFields() { $('#hamqth_info').text(""); $('#dxcc_id').val("").trigger('change'); $('#cqz').val(""); + $('#ituz').val(""); $("#locator").removeClass("workedGrid"); $("#locator").removeClass("confirmedGrid"); $("#locator").removeClass("newGrid"); @@ -1440,6 +1804,16 @@ function resetDefaultQSOFields() { $('#callsign-image-content').text(""); $('.dxccsummary').remove(); $('#timesWorked').html(lang_qso_title_previous_contacts); + + if (keepDxcc) { + $('#country').val(preservedCountry); + $('#callsign_info').text(preservedCallsignInfoText); + $('#callsign_info').attr('title', preservedCallsignInfoTitle); + $('#callsign_info').attr('class', preservedCallsignInfoClass); + $('#dxcc_id').val(preservedDxccId); + $('#cqz').val(preservedCqz); + $('#ituz').val(preservedItuz); + } } function closeModal() {