From b3cad5b953275133964707e9fd356503f57dcf09 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Wed, 1 Apr 2026 12:58:27 +0100 Subject: [PATCH 01/36] Handle Markdown horizontal rules Convert markdown horizontal rules (---, ***, ___) to
and treat
as a block-level element when protecting content from nl2br. Updated regexes to insert
, include hr in the block marker replacement, and clean up surrounding
tags so horizontal rules are not polluted by extra line breaks. --- application/views/version_dialog/index.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/application/views/version_dialog/index.php b/application/views/version_dialog/index.php index 03981fa3a..b6bfadf93 100644 --- a/application/views/version_dialog/index.php +++ b/application/views/version_dialog/index.php @@ -172,6 +172,9 @@ function($matches) { $htmlContent = preg_replace('/^### (.+)$/m', '

$1

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

$1

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

$1

', $htmlContent); + + // Convert markdown horizontal rules (---, ***, ___) + $htmlContent = preg_replace('/^[ \t]{0,3}(?:\*{3,}|-{3,}|_{3,})[ \t]*$/m', '
', $htmlContent); // Convert bold text (**text** or __text__) - must be before italic $htmlContent = preg_replace('/\*\*(.+?)\*\*/', '$1', $htmlContent); @@ -195,7 +198,7 @@ function($matches) { // 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('/(<(?:ul|ol|pre|hr\s*\/?)>)\r?\n/i', '$1', $htmlContent); // Convert line breaks to
tags $htmlContent = nl2br($htmlContent); @@ -206,8 +209,8 @@ 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); + $htmlContent = preg_replace('/\s*(<(?:h[1-6]|ul|ol|li|pre|code|hr)[^>]*>)/i', '$1', $htmlContent); + $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre)>|<(?:hr\s*\/?)>)\s*/i', '$1', $htmlContent); // Remove multiple consecutive br tags (more than 2) $htmlContent = preg_replace('/(){3,}/', '

', $htmlContent); From 868638e1b2345a4a2b8284ab4fd232f6eac0c9cc Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Wed, 1 Apr 2026 13:16:28 +0100 Subject: [PATCH 02/36] Render Markdown-like formatting in version dialog Add a lightweight Markdown-like parser for admin-provided Version Dialog text and release notes. The changes convert inline code, headers, horizontal rules, blockquotes, strikethrough, bold/italic, bullet and numbered lists (including task list checkboxes), images, links, GitHub username mentions, and auto-link URLs. It also protects block elements from nl2br, removes excessive
tags, and makes images responsive. The same processing is applied in both display locations to improve readability and preserve existing behavior when no text is set. --- application/views/version_dialog/index.php | 181 +++++++++++++++++++-- 1 file changed, 166 insertions(+), 15 deletions(-) diff --git a/application/views/version_dialog/index.php b/application/views/version_dialog/index.php index b6bfadf93..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" . $listContent . "
    \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"; }, @@ -172,9 +323,6 @@ function($matches) { $htmlContent = preg_replace('/^### (.+)$/m', '

        $1

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

        $1

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

        $1

        ', $htmlContent); - - // Convert markdown horizontal rules (---, ***, ___) - $htmlContent = preg_replace('/^[ \t]{0,3}(?:\*{3,}|-{3,}|_{3,})[ \t]*$/m', '
        ', $htmlContent); // Convert bold text (**text** or __text__) - must be before italic $htmlContent = preg_replace('/\*\*(.+?)\*\*/', '$1', $htmlContent); @@ -197,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|hr\s*\/?)>)\r?\n/i', '$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); @@ -209,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|hr)[^>]*>)/i', '$1', $htmlContent); - $htmlContent = preg_replace('/(<\/(?:h[1-6]|ul|ol|li|pre)>|<(?:hr\s*\/?)>)\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 . '

        '; From 4ab0bff623159cada1a59870eefa24ca5fd6c00d Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Thu, 2 Apr 2026 14:06:05 +0100 Subject: [PATCH 03/36] Add logbook_worked_status API endpoint Introduce POST /api/logbook_worked_status to check "worked" and "confirmed" status for a given callsign (and derived country) against a public logbook. The endpoint requires an API key, logbook_public_slug and callsign, and accepts optional band and mode. It validates input and logbook/station relationships, resolves country via DXCC lookup, maps mode to a main mode, and queries the logbook table for overall and band-specific worked status as well as LoTW/QSL confirmations. Returns structured JSON with worked and confirmed subfields and appropriate HTTP status codes (400/401/404/200). Models used: api_model, logbook_model, logbooks_model; uses configured table_name for DB queries. --- application/controllers/Api.php | 166 ++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/application/controllers/Api.php b/application/controllers/Api.php index 119f43221..1c6211c9c 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -594,6 +594,172 @@ 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 + */ + 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; + + $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, + 'worked' => [ + 'callsign_overall' => false, + 'callsign_band' => $band !== null ? false : null, + 'country_overall' => false, + 'country_band' => $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; + } + } + + $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() From 41bc5a2104b3ab0ff5b3877946d6886071372e72 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Thu, 2 Apr 2026 14:28:36 +0100 Subject: [PATCH 04/36] Add gridsquare support to worked status API Expose an optional `gridsquare` parameter to logbook_worked_status: update the docblock, parse and normalize the incoming gridsquare, and include it in the response payload. Add worked flags `grid_overall` and `grid_band` (and set them based on logbook_model->check_if_grid_worked_in_logbook results) so clients can check whether a grid square has been worked overall or on a specific band. Input is upper-cased/trimmed like other fields; existing DB logic is left intact. --- application/controllers/Api.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/application/controllers/Api.php b/application/controllers/Api.php index 1c6211c9c..5558eaa1d 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -608,6 +608,7 @@ function logbook_check_country() * Optional fields: * - band * - mode + * - gridsquare */ function logbook_worked_status() { @@ -641,6 +642,7 @@ function logbook_worked_status() $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); @@ -671,11 +673,14 @@ function logbook_worked_status() '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' => [ @@ -728,6 +733,16 @@ function logbook_worked_status() } } + 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'"); From 78209d497b67cd8372534200d946be01f5b8fc07 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Fri, 3 Apr 2026 22:00:35 +0100 Subject: [PATCH 05/36] Add API endpoint for logbook public slugs Introduce GET /api/logbook_public_slugs/{key} in the API controller to return a JSON list of the API key owner's station logbooks that have non-empty public slugs. The endpoint authorizes the key, updates last-used timestamp, responds with 401 for missing/invalid keys or 200 with status, count and logbook entries. Add Logbooks_model::public_slugs_by_user to query station_logbooks for non-null/non-empty public_slug values ordered by logbook_name. --- application/controllers/Api.php | 39 +++++++++++++++++++++++++++ application/models/Logbooks_model.php | 13 +++++++++ 2 files changed, 52 insertions(+) diff --git a/application/controllers/Api.php b/application/controllers/Api.php index 5558eaa1d..e4e9f813f 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -177,6 +177,45 @@ 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); + } + /* * diff --git a/application/models/Logbooks_model.php b/application/models/Logbooks_model.php index 00d832907..98102b424 100644 --- a/application/models/Logbooks_model.php +++ b/application/models/Logbooks_model.php @@ -243,6 +243,19 @@ 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 is_public_slug_available($slug) { // Clean public_slug $clean_slug = $this->security->xss_clean($slug); From aaf3aa85a634360b5a4655650742181c8e6c91c2 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Fri, 3 Apr 2026 22:35:25 +0100 Subject: [PATCH 06/36] Add API endpoint for accessible public slugs Introduce API method logbook_public_slugs_accessible($key) that authorizes the API key, updates last-used timestamp, and returns JSON of all station logbooks (owned or shared) that have non-empty public slugs for the key owner. Add Logbooks_model::public_slugs_accessible_by_user($user_id) to select matching logbooks, label access_level as "owner" for owners or the stored permission_level for shared entries, and order results by logbook_name. Endpoint returns 401 for missing/invalid keys and a success payload with status, count, and logbooks on success. --- application/controllers/Api.php | 40 +++++++++++++++++++++++++++ application/models/Logbooks_model.php | 23 +++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/application/controllers/Api.php b/application/controllers/Api.php index e4e9f813f..8b8ae5432 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -216,6 +216,46 @@ function logbook_public_slugs($key) ), 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); + } + /* * diff --git a/application/models/Logbooks_model.php b/application/models/Logbooks_model.php index 98102b424..78cbd78eb 100644 --- a/application/models/Logbooks_model.php +++ b/application/models/Logbooks_model.php @@ -256,6 +256,29 @@ function public_slugs_by_user($user_id) { 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); From 5d7135124daaf9b55eb2e915095639376b07215f Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Sun, 5 Apr 2026 17:36:52 +0100 Subject: [PATCH 07/36] Add AJAX QSO save endpoint and client handler Introduce an AJAX-based QSO save flow: add QSO::ajax_saveqso() in the controller to validate input, set session/cookies, create the QSO and return JSON success/error payloads. Update the QSO view to include a data-ajax-save-url on the form and add a notice-alerts container location. Enhance the qso.js client: add isSubmitting guard, showQsoNotice helper, intercept form submit to POST serialized form data to the AJAX endpoint, handle success (reset form, show notice, refresh recent QSOs via htmx), display validation/server errors, and restore UI state when complete. These changes avoid full page reloads on save and provide inline validation feedback. --- application/controllers/Qso.php | 71 +++++++++++++++++++++++++ application/views/qso/index.php | 6 ++- assets/js/sections/qso.js | 94 +++++++++++++++++++++++++++++++-- 3 files changed, 167 insertions(+), 4 deletions(-) diff --git a/application/controllers/Qso.php b/application/controllers/Qso.php index ce3df2d71..18b435f2a 100755 --- a/application/controllers/Qso.php +++ b/application/controllers/Qso.php @@ -162,6 +162,77 @@ 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') + ); + + 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'); diff --git a/application/views/qso/index.php b/application/views/qso/index.php index 4c0680677..0a7b9de5f 100755 --- a/application/views/qso/index.php +++ b/application/views/qso/index.php @@ -26,7 +26,7 @@ function switchMode(url) {
        -
        +
        \ No newline at end of file From d6ffc494f72199b2d8a0d9e40965a4c28edf3bb6 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Fri, 10 Apr 2026 11:19:25 +0100 Subject: [PATCH 25/36] Remove redundant CSS from user edit view Remove inline CSS rules from application/views/user/edit.php: deleted .user_edit custom properties (--gh-border, --gh-muted, --gh-bg-subtle) and .settings-nav .list-group-item styles (cursor, margin-bottom). This cleans up duplicated/unused styling that should be provided by global stylesheets. --- application/views/user/edit.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/application/views/user/edit.php b/application/views/user/edit.php index 760f3ec34..02438074d 100644 --- a/application/views/user/edit.php +++ b/application/views/user/edit.php @@ -37,16 +37,7 @@ load->helper('form'); ?>