wavelog/application/libraries/api_v2/Lookup_resource.php

483 lines
17 KiB
PHP

<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
require_once __DIR__ . '/Api_v2_resource.php';
// DXCC lookups live in the standalone Wavelog\Dxcc namespace (same source the v1
// API uses); pull it in and reference it fully-qualified below.
require_once APPPATH . '../src/Dxcc/Dxcc.php';
/**
* API v2 - Lookup resource (read-only)
*
* A "look something up" endpoint for a single callsign or a single gridsquare,
* scoped to the token owner's own logbook.
*
* Callsign lookup — resolves a callsign to DXCC data plus the owner's
* worked/confirmed status, LoTW membership and optional callbook data (v2
* equivalent of the v1 lookup / private_lookup endpoints; used by FlexRadio
* overlay, DXClusterAPI, ...). The ?detail= parameter selects the depth:
* - basic: DXCC + Lat/Long/continent plus the owner's grid/name if the call
* was worked before. No per-band/mode history. (v1 `lookup`, default.)
* - full: additionally per-band/mode worked & confirmed flags, DXCC
* confirmation state and (opt-in) callbook data. (v1 `private_lookup`.)
*
* Grid lookup (?grid=) — whether a gridsquare is worked/confirmed in the owner's
* logbook (v2 equivalent of v1 logbook_check_grid). The special value
* ?grid=all returns every worked gridsquare instead (v1 logbook_get_worked_grids).
*
* Routes:
* GET /api/v2/lookup?callsign=.. callsign lookup
* GET /api/v2/lookup?grid=.. grid worked/confirmed check
* GET /api/v2/lookup?grid=all list of all worked gridsquares
*
* Scope: lookup:read
*/
class Lookup_resource extends Api_v2_resource {
/** Token scope of this resource (see Api_v2_resource::required_scope()). */
protected $scope = 'lookup';
/** Registry label for this resource's scope (see scope_definitions()). */
protected static function scope_labels() {
return [
'read' => __('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);
}
}