__('Look up callsigns and grids'), ]; } /** * GET /api/v2/lookup?callsign=... or ?grid=... * `?callsign=` looks up a single callsign, `?grid=` a gridsquare's * worked/confirmed status instead. */ public function index() { $grid = $this->param('grid'); if ($grid !== null && $grid !== '') { $this->do_grid_lookup($grid); return; } $callsign = $this->param('callsign'); if ($callsign === null || $callsign === '') { throw new Api_v2_exception( 'validation_error', 'Provide a callsign (?callsign=...) or a grid (?grid=...)', 400 ); } $this->do_lookup($callsign); } /** * Callsign lookup itself. * Optional query: ?detail=full|basic (default basic), ?band=, ?mode=, * ?callbook=true, ?station_ids=1,2 (default: all of the owner's stations). */ protected function do_lookup($callsign) { $callsign = strtoupper(trim((string) $callsign)); if ($callsign === '') { throw new Api_v2_exception('validation_error', 'Missing callsign', 400); } $detail = strtolower(trim((string) $this->param('detail', 'basic'))); if (!in_array($detail, ['full', 'basic'], true)) { throw new Api_v2_exception( 'validation_error', 'Unknown detail "' . $detail . '". Allowed: full, basic', 400, ['allowed' => ['full', 'basic']] ); } $this->CI->load->model('logbook_model'); $station_ids = implode(', ', $this->resolve_station_ids('station_ids')); $data = ($detail === 'basic') ? $this->lookup_basic($callsign, $station_ids) : $this->lookup_full($callsign, $station_ids); $this->CI->api_v2_response->respond($data, 200, ['detail' => $detail]); } /** * Grid lookup (?grid=): whether the given gridsquare is worked/confirmed in * the owner's logbook. Optional ?band=, ?cnfm= (qsl|lotw|eqsl) and * ?logbook_id= filters. Result is one of "Not Found", "Found" (no cnfm * requested), "Worked", "Confirmed". Replaces the v1 logbook_check_grid. */ protected function do_grid_lookup($grid) { $grid = strtoupper(trim((string) $grid)); if ($grid === '') { throw new Api_v2_exception('validation_error', 'Missing gridsquare', 400); } // "ALL" is not a valid gridsquare (2 letters + 2 digits), so it can // safely double as the "give me every worked grid" selector. if ($grid === 'ALL') { $this->do_worked_grids(); return; } $band = $this->param('band'); $cnfm = $this->parse_cnfm(); $result = 'Not Found'; $locations = $this->grid_station_location_ids(); if (!empty($locations)) { $this->CI->load->model('logbook_model'); $query = $this->CI->logbook_model->check_if_grid_worked_in_logbook($grid, $locations, $band, $cnfm, $this->lookup_operator()); if ($query->num_rows() === 0) { $result = 'Not Found'; } elseif ($cnfm === null) { $result = 'Found'; } else { $values = []; foreach ($query->result() as $row) { $values[] = $row->gridorcnfm; } $result = in_array('Y', $values, true) ? 'Confirmed' : 'Worked'; } } $this->CI->api_v2_response->respond(['gridsquare' => $grid, 'result' => $result], 200, ['type' => 'grid']); } /** * Grid list (?grid=all): every 4-character gridsquare worked in the owner's * logbook, VUCC grids included. Optional ?band=, ?cnfm= (qsl|lotw|eqsl) and * ?logbook_id= filters. Replaces the v1 logbook_get_worked_grids. */ protected function do_worked_grids() { $band = $this->param('band'); $cnfm = $this->parse_cnfm(); $meta = ['type' => 'worked_grids', 'band' => $band, 'cnfm' => $cnfm]; // No station locations means no QSOs to look at — and an empty id list // would produce an "IN ()" SQL syntax error further down. $locations = $this->grid_station_location_ids(); if (empty($locations)) { $this->CI->api_v2_response->respond(['grids' => [], 'count' => 0], 200, $meta); return; } $this->CI->load->model('api_model'); $grids = $this->CI->api_model->get_grids_worked_in_logbook($locations, $band, $cnfm, $this->lookup_operator()); $this->CI->api_v2_response->respond(['grids' => $grids, 'count' => count($grids)], 200, $meta); } /** * Station-location ids (as an array, the form check_if_grid_worked_in_logbook * expects) the grid lookup runs against. Defaults to all of the owner's * station locations; ?logbook_id= narrows it to a single owned logbook (403 * for a foreign id). Returns [] when the logbook has no linked locations. * * @return int[] */ protected function grid_station_location_ids() { $logbook_id = $this->param('logbook_id'); if ($logbook_id !== null && $logbook_id !== '') { if (!is_numeric($logbook_id)) { throw new Api_v2_exception('validation_error', 'logbook_id must be numeric', 400); } $this->CI->load->model('logbooks_model'); if (!$this->CI->logbooks_model->logbook_id_belongs_to_user((int) $logbook_id, $this->user_id())) { throw new Api_v2_exception('forbidden', 'logbook_id does not belong to this token', 403); } $locations = $this->CI->logbooks_model->list_logbook_relationships((int) $logbook_id); return ($locations === [-1]) ? [] : $locations; } $this->CI->load->model('stations'); $ids = []; $query = $this->CI->stations->all_of_user($this->user_id()); if ($query !== null) { foreach ($query->result() as $row) { $ids[] = (int) $row->station_id; } } return $ids; } // --- Lookup variants --------------------------------------------------- /** * Reduced lookup: DXCC derivation plus the owner's grid/name when the call * was worked before. No QSO history. Mirrors the v1 `lookup` endpoint used * by the DXClusterAPI. */ protected function lookup_basic($callsign, $station_ids) { $date = date('Y-m-d'); $return = [ 'callsign' => $callsign, 'dxcc' => false, 'dxcc_id' => -1, 'dxcc_lat' => '', 'dxcc_long' => '', 'dxcc_cqz' => '', 'dxcc_flag' => '', 'cont' => '', 'name' => '', 'gridsquare' => '', 'location' => '', 'iota_ref' => '', 'state' => '', 'us_county' => '', 'qsl_manager' => '', 'bearing' => '', 'workedBefore' => false, 'lotw_member' => false, 'suffix_slash' => '', ]; $dxccobj = new \Wavelog\Dxcc\Dxcc(); $dxcc = $dxccobj->dxcc_lookup($callsign, $date); $return['dxcc_id'] = $dxcc['adif'] ?? ''; $return['dxcc'] = $dxcc['entity'] ?? ''; $return['dxcc_lat'] = $dxcc['lat'] ?? ''; $return['dxcc_long'] = $dxcc['long'] ?? ''; $return['dxcc_cqz'] = $dxcc['cqz'] ?? ''; $return['cont'] = $dxcc['cont'] ?? ''; // Owner's own record for this call (no band/mode scoping). Skipped when // the owner has no station locations: the empty id list would produce an // "IN ()" syntax error, and there is nothing to look up anyway. $hit = ($station_ids === '') ? null : $this->CI->logbook_model->call_lookup_result($callsign, $station_ids, '', 'NO BAND', 'NO MODE', $this->lookup_operator()); if ($hit != null) { $return['name'] = $hit->COL_NAME; $return['gridsquare'] = $hit->COL_GRIDSQUARE; $return['location'] = $hit->COL_QTH; $return['iota_ref'] = $hit->COL_IOTA; $return['qsl_manager'] = $hit->COL_QSL_VIA; $return['state'] = $hit->COL_STATE; $return['us_county'] = $hit->COL_CNTY; $return['workedBefore'] = true; if ($return['gridsquare'] != '') { $return['latlng'] = $this->grid_to_latlng($return['gridsquare']); } } if (($return['dxcc'] ?? '') != '') { $this->CI->load->library('DxccFlag'); $return['dxcc_flag'] = $this->CI->dxccflag->get($return['dxcc_id']); } $lotw_days = $this->CI->logbook_model->check_last_lotw($callsign); if ($lotw_days !== null) { $return['lotw_member'] = $lotw_days; } return $return; } /** * Full lookup: everything from basic plus per-band/mode worked & confirmed * flags, DXCC confirmation state and optional callbook data. Mirrors the v1 * `private_lookup` endpoint. */ protected function lookup_full($callsign, $station_ids) { $date = date('Y-m-d'); $band = (string) $this->param('band', 'NO_BAND'); $mode = (string) $this->param('mode', 'NO_MODE'); $user = $this->CI->user_model->get_by_id($this->user_id())->row(); $default_confirmation = $user->user_default_confirmation ?? ''; $return = [ 'callsign' => $callsign, 'dxcc' => false, 'dxcc_id' => -1, 'dxcc_lat' => '', 'dxcc_long' => '', 'dxcc_cqz' => '', 'dxcc_flag' => '', 'cont' => '', 'name' => '', 'gridsquare' => '', 'location' => '', 'iota_ref' => '', 'state' => '', 'us_county' => '', 'qsl_manager' => '', 'bearing' => '', 'call_worked' => false, 'call_worked_band' => false, 'call_worked_band_mode' => false, 'lotw_member' => false, 'dxcc_confirmed_on_band' => false, 'dxcc_confirmed_on_band_mode' => false, 'dxcc_confirmed' => false, 'call_confirmed' => false, 'call_confirmed_band' => false, 'call_confirmed_band_mode' => false, 'suffix_slash' => '', ]; $dxccobj = new \Wavelog\Dxcc\Dxcc(); $dxcc = $dxccobj->dxcc_lookup($callsign, $date); // A trailing "/XXX" may itself be a DXCC prefix (e.g. W/DL1ABC): resolve // it, otherwise map the common portable/mobile suffixes to a label. $last_slash_pos = strrpos($callsign, '/'); $suffix_dxcc = null; if ($last_slash_pos !== false && $last_slash_pos > 4) { $suffix = substr($callsign, $last_slash_pos + 1); switch ($suffix) { case 'P': $return['suffix_slash'] = 'Portable'; break; case 'M': $return['suffix_slash'] = 'Mobile'; break; case 'MM': $return['suffix_slash'] = 'Maritime Mobile'; break; default: // Not a known suffix: likely a DXCC prefix override. $suffix_dxcc = $dxccobj->dxcc_lookup($suffix, $date); $return['suffix_slash'] = null; } } if (isset($suffix_dxcc['call'])) { $return['dxcc_id'] = $suffix_dxcc['adif']; $return['dxcc'] = $suffix_dxcc['entity']; $return['dxcc_lat'] = $suffix_dxcc['lat']; $return['dxcc_long'] = $suffix_dxcc['long']; $return['dxcc_cqz'] = $suffix_dxcc['cqz']; $return['cont'] = $suffix_dxcc['cont']; } else { $return['dxcc_id'] = $dxcc['adif'] ?? ''; $return['dxcc'] = $dxcc['entity'] ?? ''; $return['dxcc_lat'] = $dxcc['lat'] ?? ''; $return['dxcc_long'] = $dxcc['long'] ?? ''; $return['dxcc_cqz'] = $dxcc['cqz'] ?? ''; $return['cont'] = $dxcc['cont'] ?? ''; } // ITU zone from the DXCC entity; only added when the entity has one. $entity = $this->CI->logbook_model->get_entity($return['dxcc_id']); if (is_array($entity) && (($entity['ituz'] ?? 0) > 0)) { $return['dxcc_ituz'] = (int) $entity['ituz']; } // Owner's own worked/confirmed status for this call. Skipped without any // station locations (see lookup_basic()). $hit = ($station_ids === '') ? null : $this->CI->logbook_model->call_lookup_result($callsign, $station_ids, $default_confirmation, $band, $mode, $this->lookup_operator()); if ($hit != null) { $return['name'] = $hit->COL_NAME; $return['gridsquare'] = $hit->COL_GRIDSQUARE; $return['location'] = $hit->COL_QTH; $return['iota_ref'] = $hit->COL_IOTA; $return['qsl_manager'] = $hit->COL_QSL_VIA; $return['state'] = $hit->COL_STATE; $return['us_county'] = $hit->COL_CNTY; $return['dxcc_id'] = $hit->COL_DXCC; $return['cont'] = $hit->COL_CONT; $return['call_worked'] = true; $return['call_worked_band'] = ($hit->CALL_WORKED_BAND == 1); $return['call_worked_band_mode'] = ($hit->CALL_WORKED_BAND_MODE == 1); $return['call_confirmed'] = ($hit->CALL_CNF == 1); $return['call_confirmed_band'] = ($hit->CALL_CNF_BAND == 1); $return['call_confirmed_band_mode'] = ($hit->CALL_CNF_BAND_MODE == 1); if ($return['gridsquare'] != '') { $return['latlng'] = $this->grid_to_latlng($return['gridsquare']); } } if (($return['dxcc'] ?? '') != '') { $this->CI->load->library('DxccFlag'); $return['dxcc_flag'] = $this->CI->dxccflag->get($return['dxcc_id']); } $lotw_days = $this->CI->logbook_model->check_last_lotw($callsign); if ($lotw_days !== null) { $return['lotw_member'] = $lotw_days; } if (($return['dxcc_id'] ?? '') != '') { // The model returns [] for an empty station list; cast before the // comparison, since in PHP an array is always "greater than" 0. $return['dxcc_confirmed'] = ((int) $this->CI->logbook_model->check_if_dxcc_cnfmd_in_logbook_api($default_confirmation, $return['dxcc_id'], $station_ids, null, null, $this->lookup_operator()) > 0); $return['dxcc_confirmed_on_band'] = ((int) $this->CI->logbook_model->check_if_dxcc_cnfmd_in_logbook_api($default_confirmation, $return['dxcc_id'], $station_ids, $band, null, $this->lookup_operator()) > 0); $return['dxcc_confirmed_on_band_mode'] = ((int) $this->CI->logbook_model->check_if_dxcc_cnfmd_in_logbook_api($default_confirmation, $return['dxcc_id'], $station_ids, $band, $mode, $this->lookup_operator()) > 0); } // Callbook data is opt-in (external HTTP lookup). if ($this->param('callbook') === 'true') { $this->CI->load->library('callbook'); $callbook = $this->CI->logbook_model->loadCallBook($callsign, $this->CI->config->item('use_fullname')); if ($callbook) { $return['callbook'] = $callbook; } } return $return; } // --- Helpers ----------------------------------------------------------- /** * COL_OPERATOR this lookup is restricted to, or '' for no restriction. * * A lookup answers "have I worked this before" out of the logbook, and for a * club token that logbook is the whole clubstation's. Without this the * endpoint would hand a member the name, QTH and locator recorded in another * operator's QSO - exactly the rows the QSO resource hides from it. * * @return string */ protected function lookup_operator() { return $this->is_restricted_club_member() ? (string) $this->operator_callsign() : ''; } /** * Parse and validate the ?cnfm= confirmation type, or null when absent. * * Both grid paths must validate this: check_if_grid_worked_in_logbook() * switches on the value and falls back to selecting the gridsquare itself * for anything it does not know. An unvalidated value would therefore be * compared against gridsquare substrings instead of a confirmation flag and * silently report "Worked" rather than failing. * * @return string|null 'qsl', 'lotw' or 'eqsl'. * @throws Api_v2_exception 400 on an unknown value. */ protected function parse_cnfm() { $allowed = ['qsl', 'lotw', 'eqsl']; $cnfm = $this->param('cnfm'); if ($cnfm === null || $cnfm === '') { return null; } $cnfm = strtolower(trim((string) $cnfm)); if (!in_array($cnfm, $allowed, true)) { throw new Api_v2_exception( 'validation_error', 'Unknown cnfm "' . $cnfm . '". Allowed: ' . implode(', ', $allowed), 400, ['allowed' => $allowed] ); } return $cnfm; } /** * Convert a Maidenhead locator to [lat, long] via the Qra library. */ protected function grid_to_latlng($grid) { if (!$this->CI->load->is_loaded('Qra')) { $this->CI->load->library('Qra'); } return $this->CI->qra->qra2latlong($grid); } }