diff --git a/application/config/migration.php b/application/config/migration.php index 565918e60..a691c9281 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ | */ -$config['migration_version'] = 270; +$config['migration_version'] = 271; /* |-------------------------------------------------------------------------- diff --git a/application/migrations/271_add_qrzcall_to_cloudlog.php b/application/migrations/271_add_qrzcall_to_cloudlog.php new file mode 100755 index 000000000..3282286b2 --- /dev/null +++ b/application/migrations/271_add_qrzcall_to_cloudlog.php @@ -0,0 +1,68 @@ +db->field_exists('qrzcallapikey', 'station_profile')) { + $fields = [ + 'qrzcallapikey varchar(100) DEFAULT NULL', + ]; + $this->dbforge->add_column('station_profile', $fields); + } + + // --- station_profile: real-time upload toggle --- + if (!$this->db->field_exists('qrzcallrealtime', 'station_profile')) { + $fields = [ + 'qrzcallrealtime tinyint(1) DEFAULT 0', + ]; + $this->dbforge->add_column('station_profile', $fields); + } + + // --- QSO table: upload status (NULL/N = pending, Y = uploaded, M = modified/re-upload) --- + if (!$this->db->field_exists('COL_QRZCALL_QSO_UPLOAD_STATUS', $this->config->item('table_name'))) { + $fields = [ + 'COL_QRZCALL_QSO_UPLOAD_STATUS varchar(1) DEFAULT NULL', + ]; + $this->dbforge->add_column($this->config->item('table_name'), $fields); + } + + // --- QSO table: timestamp of last successful upload --- + if (!$this->db->field_exists('COL_QRZCALL_QSO_UPLOAD_DATE', $this->config->item('table_name'))) { + $fields = [ + 'COL_QRZCALL_QSO_UPLOAD_DATE datetime DEFAULT NULL', + ]; + $this->dbforge->add_column($this->config->item('table_name'), $fields); + } + } + + public function down() + { + if ($this->db->field_exists('qrzcallapikey', 'station_profile')) { + $this->dbforge->drop_column('station_profile', 'qrzcallapikey'); + } + + if ($this->db->field_exists('qrzcallrealtime', 'station_profile')) { + $this->dbforge->drop_column('station_profile', 'qrzcallrealtime'); + } + + if ($this->db->field_exists('COL_QRZCALL_QSO_UPLOAD_STATUS', $this->config->item('table_name'))) { + $this->dbforge->drop_column($this->config->item('table_name'), 'COL_QRZCALL_QSO_UPLOAD_STATUS'); + } + + if ($this->db->field_exists('COL_QRZCALL_QSO_UPLOAD_DATE', $this->config->item('table_name'))) { + $this->dbforge->drop_column($this->config->item('table_name'), 'COL_QRZCALL_QSO_UPLOAD_DATE'); + } + } +} diff --git a/application/models/Logbook_model.php b/application/models/Logbook_model.php index e9d894d76..8c95c91e3 100755 --- a/application/models/Logbook_model.php +++ b/application/models/Logbook_model.php @@ -830,6 +830,21 @@ function add_qso($data, $skipexport = false) } } + $result = ''; // Empty result from previous attempt for safety + $result = $this->exists_qrzcall_api_key($data['station_id']); + // Push QSO to QRZCALL.EU if PAT is set and realtime upload is enabled + if (isset($result->qrzcallapikey) && !empty($result->qrzcallapikey) && $result->qrzcallrealtime == 1) { + $CI = &get_instance(); + $CI->load->library('AdifHelper'); + $qso = $this->get_qso($last_id, true)->result(); + + $adif = $CI->adifhelper->getAdifLine($qso[0]); + $upload = $this->push_qso_to_qrzcall($result->qrzcallapikey, $adif); + if ($upload['status'] == 'OK') { + $this->mark_qrzcall_qso_sent($last_id); + } + } + $result = $this->exists_webadif_api_key($data['station_id']); // Push qso to webadif if apikey is set, and realtime upload is enabled, and we're not importing an adif-file if (isset($result->webadifapikey) && $result->webadifrealtime == 1) { @@ -1079,6 +1094,78 @@ function push_qso_to_qrz($apikey, $adif, $replaceoption = false) curl_close($ch); } + /* + * Function uploads a QSO to QRZCALL.EU logbook using the QRZ-compatible endpoint. + * $pat is the user's Personal Access Token (format: pat_xxxxx). + * $adif contains a line with the QSO in the ADIF format. + */ + function push_qso_to_qrzcall($pat, $adif, $replaceoption = false) + { + $url = 'https://api.qrzcall.eu/v1/pub/logbook_api.php'; + + // Build compliant User-Agent using the shared helper, as push_qso_to_qrz() does + $this->load->helper('useragent'); + $ua = cloudlog_user_agent(); + + $post_data['KEY'] = $pat; + $post_data['ACTION'] = 'INSERT'; + $post_data['ADIF'] = $adif; + + // OPTION=REPLACE asks QRZCALL.EU to update an existing matching QSO in + // place instead of rejecting it as a duplicate — used to re-sync edited + // QSOs. Mirrors Cloudlog's QRZ.com push_qso_to_qrz(). + if ($replaceoption) { + $post_data['OPTION'] = 'REPLACE'; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); + curl_setopt($ch, CURLOPT_HEADER, 0); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_USERAGENT, $ua); + + $content = curl_exec($ch); + + if ($content) { + if (stristr($content, 'RESULT=OK') || stristr($content, 'RESULT=REPLACE')) { + $result['status'] = 'OK'; + } elseif (stristr($content, 'RESULT=FAIL') && stristr($content, 'duplicate')) { + // QRZCALL.EU reports the QSO is already in the logbook — treat it as + // success so the QSO is recorded with status 'Y' rather than flagged + // as an upload error. + $result['status'] = 'OK'; + $result['duplicate'] = true; + } else { + $result['status'] = 'error'; + $result['message'] = $content; + } + } elseif (curl_errno($ch)) { + $result['status'] = 'error'; + $result['message'] = 'Curl error: ' . curl_errno($ch); + } else { + $result['status'] = 'error'; + $result['message'] = 'Empty response from QRZCALL.EU'; + } + + curl_close($ch); + return $result; + } + + /* + * Function marks a QSO as uploaded to QRZCALL.EU + */ + function mark_qrzcall_qso_sent($primarykey) + { + $data = array( + 'COL_QRZCALL_QSO_UPLOAD_DATE' => date("Y-m-d H:i:s", strtotime("now")), + 'COL_QRZCALL_QSO_UPLOAD_STATUS' => 'Y', + ); + $this->db->where('COL_PRIMARY_KEY', $primarykey); + $this->db->update($this->config->item('table_name'), $data); + } + /* * Function uploads a QSO to WebADIF consumer with the API given. * $adif contains a line with the QSO in the ADIF format. @@ -1547,7 +1634,24 @@ function edit() $this->db->where('COL_PRIMARY_KEY', $this->input->post('id')); $this->db->update($this->config->item('table_name'), $data); - + + // Real-time re-upload of the edited QSO to QRZCALL.EU. The QSO already + // exists there from the create-time upload, so it is sent with + // OPTION=REPLACE — QRZCALL.EU updates the existing record in place. + $qrzcall_station = $this->exists_qrzcall_api_key($data['station_id']); + if (isset($qrzcall_station->qrzcallapikey) && !empty($qrzcall_station->qrzcallapikey) && $qrzcall_station->qrzcallrealtime == 1) { + $CI = &get_instance(); + $CI->load->library('AdifHelper'); + $qso = $this->get_qso($this->input->post('id'), true)->result(); + if (!empty($qso)) { + $adif = $CI->adifhelper->getAdifLine($qso[0]); + $upload = $this->push_qso_to_qrzcall($qrzcall_station->qrzcallapikey, $adif, true); + if ($upload['status'] == 'OK') { + $this->mark_qrzcall_qso_sent($this->input->post('id')); + } + } + } + // Clear dashboard cache for affected station $this->clear_dashboard_cache($stationId); @@ -2230,6 +2334,24 @@ function get_station_id_with_qrz_api() } } + /* + * Function checks if a QRZCALL.EU PAT exists for the given station id + */ + function exists_qrzcall_api_key($station_id) + { + $sql = 'select qrzcallapikey, qrzcallrealtime from station_profile + where station_id = ?'; + + $query = $this->db->query($sql, $station_id); + $result = $query->row(); + + if ($result) { + return $result; + } else { + return false; + } + } + /* * Function returns all the station_id's with HRDLOG Code */ diff --git a/application/models/Stations.php b/application/models/Stations.php index 2da8bd0d3..154e21435 100644 --- a/application/models/Stations.php +++ b/application/models/Stations.php @@ -138,6 +138,8 @@ function add() { 'webadifapikey' => xss_clean($this->input->post('webadifapikey', true)), 'webadifapiurl' => 'https://qo100dx.club/api', 'webadifrealtime' => xss_clean($this->input->post('webadifrealtime', true)), + 'qrzcallapikey' => xss_clean($this->input->post('qrzcallapikey', true)), + 'qrzcallrealtime' => xss_clean($this->input->post('qrzcallrealtime', true)), ); // Insert Records & return insert id // @@ -189,6 +191,8 @@ function edit() { 'webadifapikey' => xss_clean($this->input->post('webadifapikey', true)), 'webadifapiurl' => 'https://qo100dx.club/api', 'webadifrealtime' => xss_clean($this->input->post('webadifrealtime', true)), + 'qrzcallapikey' => xss_clean($this->input->post('qrzcallapikey', true)), + 'qrzcallrealtime' => xss_clean($this->input->post('qrzcallrealtime', true)), ); $this->db->where('user_id', $this->session->userdata('user_id')); diff --git a/application/views/station_profile/create.php b/application/views/station_profile/create.php index 6ea0d4b07..5f76336df 100644 --- a/application/views/station_profile/create.php +++ b/application/views/station_profile/create.php @@ -323,6 +323,21 @@ function validateInput(input) { +
+
+ + + Generate at qrzcall.eu → My Profile → Account → API Tokens (Data / Extra tier) +
+
+ + +
+
+
diff --git a/application/views/station_profile/edit.php b/application/views/station_profile/edit.php index c0b194300..8e776fb4b 100644 --- a/application/views/station_profile/edit.php +++ b/application/views/station_profile/edit.php @@ -1045,6 +1045,28 @@
+
+
+
+
QRZCALL.EU Data / Extra
+
+
+ + qrzcallapikey; } ?>"> + Generate at qrzcall.eu → My Profile → Account → API Tokens (requires Data or Extra subscription) +
+
+ + +
+
+
+
+ +
diff --git a/cypress/e2e/6-qrzcall.cy.js b/cypress/e2e/6-qrzcall.cy.js new file mode 100755 index 000000000..93ad6111e --- /dev/null +++ b/cypress/e2e/6-qrzcall.cy.js @@ -0,0 +1,44 @@ +describe("QRZCALL.EU integration", () => { + beforeEach(() => { + cy.login(); + }); + + it("should show the QRZCALL.EU PAT field on the station create page", () => { + cy.visit("/index.php/station/create"); + + // The Personal Access Token text input should be present + cy.get('input[name="qrzcallapikey"]') + .should("exist") + .and("be.visible") + .and("have.attr", "placeholder", "pat_xxxxx"); + + // The real-time upload select should be present with Yes/No options + cy.get('select[name="qrzcallrealtime"]') + .should("exist") + .find("option") + .should("have.length", 2); + }); + + it("should accept a Personal Access Token in the QRZCALL.EU field", () => { + cy.visit("/index.php/station/create"); + + const testPat = "pat_cypresstest1234567890"; + + cy.get('input[name="qrzcallapikey"]') + .scrollIntoView() + .type(testPat, { force: true }) + .should("have.value", testPat); + + // Real-time upload defaults to "No" + cy.get('select[name="qrzcallrealtime"]') + .find("option:selected") + .should("have.value", "0"); + + // And can be switched to "Yes" (force: the QRZCALL.EU card can sit + // under the station form's sticky action bar in the test viewport) + cy.get('select[name="qrzcallrealtime"]').select("1", { force: true }); + cy.get('select[name="qrzcallrealtime"]') + .find("option:selected") + .should("have.value", "1"); + }); +});