Skip to content

Add QRZCALL.EU as a QSO upload target#3439

Open
Ron6519 wants to merge 1 commit into
magicbug:devfrom
Ron6519:qrzcall-qso-upload
Open

Add QRZCALL.EU as a QSO upload target#3439
Ron6519 wants to merge 1 commit into
magicbug:devfrom
Ron6519:qrzcall-qso-upload

Conversation

@Ron6519
Copy link
Copy Markdown

@Ron6519 Ron6519 commented May 16, 2026

Add QRZCALL.EU as a QSO upload target

This PR lets Cloudlog mirror QSOs to a user's QRZCALL.EU
logbook in real time, alongside the existing QRZ.com / Club Log / HRDLog
upload targets.

It builds on the QRZCALL.EU callbook-provider PR, but is functionally
independent — the callbook lookup reads a token from config.php, whereas
QSO upload uses a per-station-profile PAT. The two can be reviewed and merged
in either order.


How it works

QRZCALL.EU exposes a QRZ-compatible logbook endpoint
(api.qrzcall.eu/v1/pub/logbook_api.php) that accepts the same
KEY=&ACTION=INSERT&ADIF= request format Cloudlog already uses for QRZ.com.

Upload is real-time and happens automatically for station profiles that
have a QRZCALL.EU PAT with real-time upload enabled:

  • QSO logged (create_qso()) → the new QSO is sent immediately (INSERT)
  • QSO edited (edit()) → the changed QSO is sent immediately with
    OPTION=REPLACE, so QRZCALL.EU updates the existing record in place rather
    than creating a duplicate

There is no bulk / cron upload route — synchronisation is real-time only.
Bulk import/export is handled on the QRZCALL.EU website itself, not from
external loggers.

Each upload records its status and timestamp on the QSO
(COL_QRZCALL_QSO_UPLOAD_STATUS / _DATE).


Changes

7 files — 2 new, 5 modified

File Change
application/migrations/271_add_qrzcall_to_cloudlog.php NEW — adds qrzcallapikey / qrzcallrealtime to station_profile; adds COL_QRZCALL_QSO_UPLOAD_STATUS / COL_QRZCALL_QSO_UPLOAD_DATE to the QSO table
cypress/e2e/6-qrzcall.cy.js NEW — Cypress E2E test for the QRZCALL.EU station-profile fields
application/models/Logbook_model.php push_qso_to_qrzcall() (sends OPTION=REPLACE for edits; treats a duplicate response as success), mark_qrzcall_qso_sent(), exists_qrzcall_api_key(); real-time INSERT hook in create_qso(); real-time REPLACE hook in edit()
application/config/migration.php Bumps migration_version to 271
application/models/Stations.php Saves qrzcallapikey / qrzcallrealtime in create() and edit()
application/views/station_profile/edit.php QRZCALL.EU PAT field + real-time upload select
application/views/station_profile/create.php Same fields on the create form

Configuration

Station Profile → enter the QRZCALL.EU PAT and set real-time upload to Yes.
QSOs logged or edited under that station profile then sync automatically.


Testing

Verified on the Docker development environment (PHP 7.4, MySQL):

  • Migration 271 applies cleanly (idempotent — field_exists guards)
  • Real-time create — QSO logged → immediately uploaded (INSERT), status Y
  • Real-time edit — QSO edited → immediately re-sent with OPTION=REPLACE;
    QRZCALL.EU updates the existing record in place (verified RESULT=REPLACE,
    same logbook ID, no duplicate row)
  • Invalid PAT → the upload call fails cleanly, the QSO's status is left
    untouched (no retry loop — the hook fires once per save)
  • New Cypress test 6-qrzcall.cy.js passes; existing suite unaffected

Test account — a shared account is available for reviewers:

Callsign:  TEST0WVLG
PAT:       pat_b3gvr7n6wyzjvs9xk4jmpgxm3r3gtgdx

Enter the PAT in a station profile (Station Profile → QRZCALL.EU card), enable
real-time upload, then log or edit a QSO.


Screenshot

The QRZCALL.EU card on the station profile (PAT field + real-time toggle) is
attached to this PR.


No breaking changes

All changes are additive. Upload only happens for station profiles that have
a QRZCALL.EU PAT set with real-time upload enabled. Existing QRZ.com / Club Log
/ HRDLog upload behaviour is unchanged.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds QRZCALL.EU as a real-time QSO upload target alongside the existing QRZ.com / Club Log / HRDLog integrations. A new station-profile field stores a per-user Personal Access Token (PAT), and the Logbook_model pushes newly logged QSOs (INSERT) and edited QSOs (REPLACE) to the QRZ-compatible api.qrzcall.eu/v1/pub/logbook_api.php endpoint, marking each QSO's upload status.

Changes:

  • New migration 271_add_qrzcall_to_cloudlog.php adds qrzcallapikey / qrzcallrealtime to station_profile and COL_QRZCALL_QSO_UPLOAD_STATUS / _DATE to the QSO table.
  • Logbook_model gains push_qso_to_qrzcall(), mark_qrzcall_qso_sent(), exists_qrzcall_api_key(), plus hooks in add_qso() and edit(); Stations model persists the new profile fields; create/edit views expose the PAT and real-time toggle.
  • New Cypress test 6-qrzcall.cy.js verifies the new station-profile UI fields.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
application/migrations/271_add_qrzcall_to_cloudlog.php Adds QRZCALL.EU schema columns with idempotent guards.
application/config/migration.php Bumps migration_version to 271.
application/models/Logbook_model.php Implements real-time INSERT/REPLACE upload to QRZCALL.EU and result handling.
application/models/Stations.php Saves new qrzcallapikey / qrzcallrealtime fields on create/edit.
application/views/station_profile/edit.php Adds QRZCALL.EU PAT + real-time toggle card to edit form.
application/views/station_profile/create.php Adds matching PAT + real-time toggle fields to create form.
cypress/e2e/6-qrzcall.cy.js E2E test for the new station-profile UI controls.
Comments suppressed due to low confidence (7)

application/models/Logbook_model.php:1656

  • Unlike the existing HRDLog/QRZ.com/Clublog services, the QRZCALL.EU upload status column (COL_QRZCALL_QSO_UPLOAD_STATUS) is never set to 'M' here when a QSO is edited. The migration documents M = modified/re-upload, but the edit flow only sets the status to 'Y' via mark_qrzcall_qso_sent after a successful REPLACE. If the real-time REPLACE fails (network/auth error, or the user has since rotated the PAT), the QSO will continue to display its old 'Y'/upload date with no indication it has been modified and is now out of sync with QRZCALL.EU. Consider setting COL_QRZCALL_QSO_UPLOAD_STATUS = 'M' in the $data array alongside the other services, mirroring lines 1614–1624.
    if ($this->exists_hrdlog_credentials($data['station_id'])) {
      $data['COL_HRDLOG_QSO_UPLOAD_STATUS'] = 'M';
    }

    if ($this->exists_qrz_api_key($data['station_id'])) {
      $data['COL_QRZCOM_QSO_UPLOAD_STATUS'] = 'M';
    }

    if ($this->exists_clublog_credentials($data['station_id'])) {
      $data['COL_CLUBLOG_QSO_UPLOAD_STATUS'] = 'M';
    }

    // Reset LoTW and eQSL sent status to 'N' if they were previously sent
    // This ensures edited QSOs get re-uploaded to these services
    if ($qso->COL_LOTW_QSL_SENT == 'Y' && $data['COL_LOTW_QSL_SENT'] == 'Y') {
      $data['COL_LOTW_QSL_SENT'] = 'N';
      $data['COL_LOTW_QSLSDATE'] = null;
    }

    if ($qso->COL_EQSL_QSL_SENT == 'Y' && $data['COL_EQSL_QSL_SENT'] == 'Y') {
      $data['COL_EQSL_QSL_SENT'] = 'N';
      $data['COL_EQSL_QSLSDATE'] = null;
    }

    $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'));
        }
      }
    }

application/models/Logbook_model.php:1141

  • stristr($content, 'duplicate') is too loose a check. The QRZ-compatible logbook endpoint typically returns structured fields like RESULT=FAIL&REASON=...duplicate...; matching the bare substring duplicate anywhere in the response means an unrelated error message that happens to mention the word (e.g. in an error explanation) would be silently treated as a successful upload and the QSO would be marked Y. The existing QRZ.com handling at line 828 matches the full reason string (STATUS=FAIL&REASON=Unable to add QSO to database: duplicate&EXTENDED=). Consider tightening this check similarly.
      } elseif (stristr($content, 'duplicate')) {
        // QRZCALL.EU rejected the QSO because an identical one is already in
        // the logbook. Treat it as success so the QSO is marked uploaded and
        // not retried on every run (mirrors Cloudlog's QRZ.com handling).
        $result['status']    = 'OK';
        $result['duplicate'] = true;
        return $result;

application/models/Logbook_model.php:1138

  • The comment claims duplicates are treated as success "so the QSO is marked uploaded and not retried on every run", but as stated in the PR description there is no bulk/cron upload route for QRZCALL.EU — synchronisation is real-time only and fires exactly once per save. The justification doesn't apply to QRZCALL.EU in its current form; consider clarifying the rationale (e.g. it just normalises an already-uploaded QSO to status Y).
        // QRZCALL.EU rejected the QSO because an identical one is already in
        // the logbook. Treat it as success so the QSO is marked uploaded and
        // not retried on every run (mirrors Cloudlog's QRZ.com handling).

application/models/Logbook_model.php:1157

  • curl_close($ch) is called immediately after curl_exec(), but the curl_errno($ch) check below it executes against a closed handle. In PHP, once the handle is closed the errno is no longer meaningful, so this branch will never report a real curl error — only the surrounding if ($content) block can return a result. When curl_exec returns false (network failure, TLS error, etc.) this function falls through and returns null, which then trips $upload['status'] access in the callers (add_qso/edit) with an "undefined index" notice. Move curl_close() to after the curl_errno check (as the WebADIF/HRDLog/QRZ.com functions do) and ensure a final error result is returned when $content is false.
    $content = curl_exec($ch);
    curl_close($ch);

    if ($content) {
      if (stristr($content, 'RESULT=OK') || stristr($content, 'RESULT=REPLACE')) {
        $result['status'] = 'OK';
        return $result;
      } elseif (stristr($content, 'duplicate')) {
        // QRZCALL.EU rejected the QSO because an identical one is already in
        // the logbook. Treat it as success so the QSO is marked uploaded and
        // not retried on every run (mirrors Cloudlog's QRZ.com handling).
        $result['status']    = 'OK';
        $result['duplicate'] = true;
        return $result;
      } elseif (stristr($content, 'RESULT=AUTH')) {
        $result['status'] = 'error';
        $result['message'] = $content;
        return $result;
      } else {
        $result['status'] = 'error';
        $result['message'] = $content;
        return $result;
      }
    }
    if (curl_errno($ch)) {
      $result['status'] = 'error';
      $result['message'] = 'Curl error: ' . curl_errno($ch);
      return $result;
    }
  }

application/models/Logbook_model.php:1656

  • When a user edits a QSO, this code unconditionally sends OPTION=REPLACE. If the user only enabled the QRZCALL.EU PAT (or real-time upload) after the QSO was originally created, the QSO does not exist on QRZCALL.EU and a REPLACE will fail. The QSO will then never be uploaded via the edit path. Consider checking whether the QSO was previously uploaded (e.g. COL_QRZCALL_QSO_UPLOAD_STATUS == 'Y') and falling back to a plain INSERT otherwise — or simply always using INSERT, since the duplicate-handling branch already covers the re-upload case.
    // 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'));
        }
      }
    }

application/models/Logbook_model.php:2356

  • exists_qrzcall_api_key() returns the row even when qrzcallapikey is NULL/empty, so the function name is slightly misleading — it doesn't actually verify a key exists. The callers compensate with !empty($result->qrzcallapikey) checks, but consider either adding AND qrzcallapikey IS NOT NULL AND qrzcallapikey != '' to the SQL or renaming to get_qrzcall_credentials to match exists_hrdlog_credentials / exists_clublog_credentials semantics (which similarly just fetch the row).
  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;
    }
  }

application/views/station_profile/edit.php:1055

  • The QRZCALL.EU PAT field uses type="text", which renders the secret token in plain view and lets the browser autofill/store it like any text input. Other secret-bearing inputs on this form (e.g. existing QRZ/Clublog/HRDLog credential fields, depending on how they're rendered) are typically masked. Consider using type="password" (or at least autocomplete="off") to avoid casual shoulder-surfing exposure of the Personal Access Token. Applies to both create.php (line 329) and edit.php (line 1055).
						<input type="text" class="form-control" name="qrzcallapikey" id="qrzcallApiKey" aria-describedby="qrzcallApiKeyHelp" placeholder="pat_xxxxx" value="<?php if(set_value('qrzcallapikey') != "") { echo set_value('qrzcallapikey'); } else { echo $my_station_profile->qrzcallapikey; } ?>">

Comment thread application/models/Logbook_model.php Outdated
Comment on lines +1106 to +1107
$ci = & get_instance();
$ua = 'Cloudlog/' . $ci->config->item('app_version');
Comment on lines +1051 to +1064
<h5 class="card-header">QRZCALL.EU <span class="badge text-bg-warning">Data / Extra</span></h5> <!-- This does not need Multilanguage Support -->
<div class="card-body">
<div class="mb-3">
<label for="qrzcallApiKey">QRZCALL.EU Personal Access Token</label> <!-- This does not need Multilanguage Support -->
<input type="text" class="form-control" name="qrzcallapikey" id="qrzcallApiKey" aria-describedby="qrzcallApiKeyHelp" placeholder="pat_xxxxx" value="<?php if(set_value('qrzcallapikey') != "") { echo set_value('qrzcallapikey'); } else { echo $my_station_profile->qrzcallapikey; } ?>">
<small id="qrzcallApiKeyHelp" class="form-text text-muted">Generate at <a href="https://qrzcall.eu/" target="_blank">qrzcall.eu</a> → My Profile → Account → API Tokens (requires Data or Extra subscription)</small>
</div>
<div class="mb-3">
<label for="qrzcallrealtime">Real-time QSO upload</label> <!-- This does not need Multilanguage Support -->
<select class="form-select" id="qrzcallrealtime" name="qrzcallrealtime">
<option value="1" <?php if ($my_station_profile->qrzcallrealtime == 1) { echo " selected=\"selected\""; } ?>><?php echo lang("general_word_yes"); ?></option>
<option value="0" <?php if ($my_station_profile->qrzcallrealtime == 0) { echo " selected=\"selected\""; } ?>><?php echo lang("general_word_no"); ?></option>
</select>
</div>
@Ron6519 Ron6519 force-pushed the qrzcall-qso-upload branch from 1833a35 to 8f4b08d Compare May 16, 2026 19:06
@Ron6519
Copy link
Copy Markdown
Author

Ron6519 commented May 16, 2026

Thanks for the review. Fixed the valid points; checked each against the existing QRZ.com implementation.

Fixed in push_qso_to_qrzcall()

  • curl_close() moved after the curl_errno() check, and the function now always returns a result (no more possible null) — matches push_qso_to_qrz().
  • User-Agent now uses the shared cloudlog_user_agent() helper instead of building it inline.
  • Duplicate check tightened from a bare duplicate substring to RESULT=FAIL + duplicate.
  • Corrected the stale comment (it referred to a bulk/retry path this PR does not have).

Checked but left as-is — already consistent with QRZ.com

  • PAT field type="text": the existing qrzapikey, hrdlog_code and webadifapikey fields in this same station-profile view are all type="text" — the QRZCALL.EU field matches that convention.
  • exists_qrzcall_api_key(): identical pattern to the existing exists_qrz_api_key() (returns the row; callers check the key) — kept consistent.
  • Status 'M' on edit: QRZ.com's 'M' feeds its bulk re-upload queue; this integration is real-time only, so an edit re-uploads immediately with OPTION=REPLACE and there is no 'M' queue to populate.

Verified on the Docker dev environment: real-time create, edit (REPLACE) and duplicate handling all confirmed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants