diff --git a/okapi/core/OkapiServiceRunner.php b/okapi/core/OkapiServiceRunner.php index a1d0abba..ed5149c3 100644 --- a/okapi/core/OkapiServiceRunner.php +++ b/okapi/core/OkapiServiceRunner.php @@ -39,10 +39,12 @@ class OkapiServiceRunner 'services/caches/geocaches', 'services/caches/mark', 'services/caches/save_personal_notes', + 'services/caches/save_user_coords', 'services/caches/formatters/gpx', 'services/caches/formatters/garmin', 'services/caches/formatters/ggz', 'services/caches/map/tile', + 'services/draftlogs/upload_fieldnotes', 'services/logs/capabilities', 'services/logs/delete', 'services/logs/edit', diff --git a/okapi/services/apisrv/installation/WebService.php b/okapi/services/apisrv/installation/WebService.php index a995e984..b922284d 100644 --- a/okapi/services/apisrv/installation/WebService.php +++ b/okapi/services/apisrv/installation/WebService.php @@ -33,7 +33,14 @@ public static function call(OkapiRequest $request) $result['has_image_positions'] = Settings::get('OC_BRANCH') == 'oc.de'; $result['has_ratings'] = Settings::get('OC_BRANCH') == 'oc.pl'; $result['geocache_passwd_max_length'] = Db::field_length('caches', 'logpw'); + $result['has_draft_logs'] = Settings::get('OC_BRANCH') == 'oc.de'; + $result['has_lists'] = Settings::get('OC_BRANCH') == 'oc.de'; + if (Settings::get('OC_BRANCH') == 'oc.de') { + $result['cache_types'] = Okapi::get_local_cachetypes(); + $result['log_types'] = Okapi::get_submittable_logtype_names(); + } return Okapi::formatted_response($request, $result); } + } diff --git a/okapi/services/apisrv/installation/docs.xml b/okapi/services/apisrv/installation/docs.xml index 87151709..f1798892 100644 --- a/okapi/services/apisrv/installation/docs.xml +++ b/okapi/services/apisrv/installation/docs.xml @@ -107,6 +107,29 @@

geocache_passwd_max_length - the maximum length of geocache log passwords.

+
  • +

    has_draft_logs - boolean, true if this installation + supports draft log entries (fieldnotes). Use this to determine whether + services/draftlogs/upload_fieldnotes + is available.

    +
  • +
  • +

    has_lists - boolean, true if this installation + supports geocache lists. Use this to determine whether the + services/lists/* family of methods is available.

    +
  • +
  • +

    cache_types - list of cache type names supported by this + installation. Only present when has_draft_logs is true. + Values are OKAPI cache type names (e.g. Traditional, + Multi-cache).

    +
  • +
  • +

    log_types - list of log type names that can be submitted + on this installation. Only present when has_draft_logs is true. + Use these values when uploading fieldnotes via + services/draftlogs/upload_fieldnotes.

    +
  • diff --git a/okapi/services/caches/save_user_coords/WebService.php b/okapi/services/caches/save_user_coords/WebService.php new file mode 100644 index 00000000..bdac1be3 --- /dev/null +++ b/okapi/services/caches/save_user_coords/WebService.php @@ -0,0 +1,108 @@ + 3 + ); + } + + public static function call(OkapiRequest $request) + { + + $user_coords = $request->get_parameter('user_coords'); + if ($user_coords == null) + throw new ParamMissing('user_coords'); + $parts = explode('|', $user_coords); + if (count($parts) != 2) + throw new InvalidParam('user_coords', "Expecting 2 pipe-separated parts, got ".count($parts)."."); + foreach ($parts as &$part_ref) + { + if (!preg_match("/^-?[0-9]+(\.?[0-9]*)$/", $part_ref)) + throw new InvalidParam('user_coords', "'$part_ref' is not a valid float number."); + $part_ref = floatval($part_ref); + } + list($latitude, $longitude) = $parts; + + # Verify cache_code + + $cache_code = $request->get_parameter('cache_code'); + if ($cache_code == null) + throw new ParamMissing('cache_code'); + $geocache = OkapiServiceRunner::call( + 'services/caches/geocache', + new OkapiInternalRequest($request->consumer, $request->token, array( + 'cache_code' => $cache_code, + 'fields' => 'internal_id' + )) + ); + $cache_id = $geocache['internal_id']; + + self::update_notes($cache_id, $request->token->user_id, $latitude, $longitude); + + $ret_value = 'ok'; + + $result = array( + 'status' => $ret_value + ); + return Okapi::formatted_response($request, $result); + } + + private static function update_notes($cache_id, $user_id, $latitude, $longitude) + { + /* See: + * + * - https://github.com/OpencachingDeutschland/oc-server3/tree/development/htdocs/src/Oc/Libse/CacheNote + * - https://www.opencaching.de/okapi/devel/dbstruct + */ + + $rs = Db::query(" + select max(id) as id + from coordinates + where + type = 2 -- personal note + and cache_id = '".Db::escape_string($cache_id)."' + and user_id = '".Db::escape_string($user_id)."' + "); + $id = null; + if($row = Db::fetch_assoc($rs)) { + $id = $row['id']; + } + if ($id == null) { + Db::query(" + insert into coordinates ( + type, latitude, longitude, cache_id, user_id + ) values ( + 2, + '".Db::escape_string($latitude)."', + '".Db::escape_string($longitude)."', + '".Db::escape_string($cache_id)."', + '".Db::escape_string($user_id)."' + ) + "); + } else { + Db::query(" + update coordinates + set latitude = '".Db::escape_string($latitude)."', + longitude = '".Db::escape_string($longitude)."', + where + id = '".Db::escape_string($id)."' + and type = 2 + "); + } + } + +} diff --git a/okapi/services/caches/save_user_coords/docs.xml b/okapi/services/caches/save_user_coords/docs.xml new file mode 100644 index 00000000..9689200d --- /dev/null +++ b/okapi/services/caches/save_user_coords/docs.xml @@ -0,0 +1,29 @@ + + Update personal coordinates of a geocache + 629 + +

    This method allows your users to update the coordinates of their + personal geocache coordinates.

    + +

    Current personal coordinates for the geocache can be retrieved + using the alt_wpts field in the + services/caches/geocache + method.

    +
    + +

    Code of the geocache

    +
    + +

    The coordinates are defined by a string in the format "lat|lon"

    +

    Use positive numbers for latitudes in the northern hemisphere and longitudes + in the eastern hemisphere (and negative for southern and western hemispheres + accordingly). These are full degrees with a dot as a decimal point (ex. "48.7|15.89").

    +
    + + +

    A dictionary of the following structure:

    +
      +
    • status - ok
    • +
    +
    +
    diff --git a/okapi/services/draftlogs/upload_fieldnotes/WebService.php b/okapi/services/draftlogs/upload_fieldnotes/WebService.php new file mode 100644 index 00000000..f965c301 --- /dev/null +++ b/okapi/services/draftlogs/upload_fieldnotes/WebService.php @@ -0,0 +1,232 @@ + 3 + ); + } + + public static function call(OkapiRequest $request) + { + if (Settings::get('OC_BRANCH') != 'oc.de') + throw new BadRequest('This method is not supported in this OKAPI installation. See the has_draft_logs field in services/apisrv/installation method.'); + + $field_notes = $request->get_parameter('field_notes'); + if (!$field_notes) throw new ParamMissing('field_notes'); + + // In order to understand the following, some serious explanations are in order. We are dealing here with a + // string that resembles multiple CSV records. It is important to understand, that a line, identified by a line + // termination character /n is not a 1:1 match withe a CSV record. In fact multiple such lines can be part of one + // CSV record so this input variable has to treated very carefully. What complicates this further is that we cannot + // dictate the character encoding "by design" as there are legacy applictions which have a hardcoded behaviour of + // using UTF-16LE with no BOM. This encoding has been devised by Garmin and Groundspeak a very long time ago. More + // modern applications use UTF-8 but we're best advised if we're tolerant to the character encoding which means + // we must reliably detect it and convert it to UTF-8 ourselves. + // + // Further we accept input data as a base64 encoded string. This primarily because the OKAPI Browser (a Windows application) + // cannot deal with multiline string inputs, however, debugging a webservice like this is hardly possible without having + // the OKAPI browser at hands, so we just accept the input string either plain oder base64 encoded. + + //First figure out whether it is base64 or not. If it is, decode it. + + if (self::is_base64($field_notes)) { + $input = base64_decode($field_notes, true); + } else { + $input = $field_notes; + } + + // At this point we're dealing with the plain $input string, we need to figure out the encoding and convert + // to UTF-8. There is no single library function which proved to reliably identify the character encoding + // for instance mb_detect_encoding() miserably failed identifying UTF-LE w/o BOM correctly, consequently + // it is the safest approach to do this manually with just a few lines of code which can be understood + // by looking at it at a glance. + + if (strlen($input) < 3) { + throw new InvalidParam('field_notes', "Input data is too short to be valid."); + } + + switch (true) { + case $input[0] === "\xEF" && $input[1] === "\xBB" && $input[2] === "\xBF": // UTF-8 BOM + $output = substr($input, 3); + break; + case $input[0] === "\xFE" && $input[1] === "\xFF": // UTF-16BE BOM + case $input[0] === "\x00" && $input[2] === "\x00": + $output = mb_convert_encoding($input, 'UTF-8', 'UTF-16BE'); + break; + case $input[0] === "\xFF" && $input[1] === "\xFE": // UTF-16LE BOM + case $input[1] === "\x00": + $output = mb_convert_encoding($input, 'UTF-8', 'UTF-16LE'); + break; + default: + $output = $input; + } + + $notes = self::parse_notes($output); + $processed_records = 0; + + foreach ($notes['records'] as $n) + { + try { + $geocache = OkapiServiceRunner::call( + 'services/caches/geocache', + new OkapiInternalRequest($request->consumer, $request->token, array( + 'cache_code' => $n['code'], + 'fields' => 'internal_id' + )) + ); + } catch (\Exception $e) { + continue; + } + + $date_timestamp = strtotime($n['date']); + if ($date_timestamp === false) { + continue; + } + $date = date("Y-m-d H:i:s", $date_timestamp); + + $type = Okapi::logtypename2id($n['type']); + $user_id = $request->token->user_id; + $geocache_id = $geocache['internal_id']; + $text = $n['log']; + + Db::query(" + insert into field_note ( + user_id, geocache_id, type, date, text + ) values ( + '".Db::escape_string($user_id)."', + '".Db::escape_string($geocache_id)."', + '".Db::escape_string($type)."', + '".Db::escape_string($date)."', + '".Db::escape_string($text)."' + ) + "); + $processed_records++; + } + + // total_records is the number of CSV records found in the input. + // processed_records is the number actually inserted; it may be less + // because fieldnotes from multi-platform apps contain records for + // other platforms (GC, OP, …) and log types not supported here. + + $result = array( + 'success' => true, + 'total_records' => $notes['total_records'], + 'processed_records' => $processed_records + ); + return Okapi::formatted_response($request, $result); + } + + // ------------------------------------------------------------------ + // Operates on a sanitized utf-8 string of what is known as "Fieldnotes" + // A fieldnotes are a list of CSV formatted records condensed into a + // single string stretching across multiple "lines" where lines are marked + // and terminated by linefeed characters \n. In its simplest form a record + // matches a line, e.g.: + // + // OC1012,2023-11-27T08:27:48Z,Found it,"Thx to Retriever12 for the cache" + // + // This example shows that each record consist of four fields: + // cache_code, log date, log type, and a draft log text + // + // What makes this challenging to parse is that the draft log can be very + // long and it can itself contain line control characters so it stretches + // across multiple lines in string. + + private static function parse_notes($field_notes) + { + $lines = self::parse_csv($field_notes); + $submittable_logtype_names = Okapi::get_submittable_logtype_names(); + $records = []; + $total_records = 0; + + foreach ($lines as $line) { + $total_records++; + $line = trim($line); + $fields = str_getcsv($line); + + $code = $fields[0]; + $date = $fields[1]; + $type = $fields[2]; + + if (!in_array($type, $submittable_logtype_names)) continue; + + $log = mb_substr($fields[3], 0, 255); + + $records[] = [ + 'code' => $code, + 'date' => $date, + 'type' => $type, + 'log' => $log, + ]; + } + return ['records' => $records, 'total_records' => $total_records]; + } + + + // ------------------------------------------------------------------ + // Split lines into an array of records. Each element in the $output + // array will then contain a string, which can strech across multiple + // lines, each terminated with a linefeed \n. + // + // In this process we also skip records that will not be understood + // by the platform, where platform is one of: geocaching.com, opencaching.{de,pl,...} + // + // In this function we ony take log records which start with "OC" (for opencaching.de) + + private static function parse_csv($field_notes) + { + $output = []; + $buffer = ''; + $start = true; + + $lines = explode("\n", $field_notes); + $lines = array_filter($lines); // Drop empty lines + + foreach ($lines as $line) { + if ($start) { + $buffer = $line; + $start = false; + } else { + // A new record starts with a cache code followed by an ISO date + if (preg_match('/^OC\w+,\d{4}-/', $line)) { + $output[] = trim($buffer); + $buffer = $line; + } else { + $buffer .= "\n" . $line; + } + } + } + + if (!$start) { + $output[] = trim($buffer); + } + return $output; + } + + // ------------------------------------------------------------------ + // Check whether a string ($s) is base64 encoded or not. + + private static function is_base64($s) + { + $decoded = base64_decode($s, true); + if ($decoded === false) { + return false; + } + return base64_encode($decoded) === $s; + } +} diff --git a/okapi/services/draftlogs/upload_fieldnotes/docs.xml b/okapi/services/draftlogs/upload_fieldnotes/docs.xml new file mode 100644 index 00000000..5683b8ea --- /dev/null +++ b/okapi/services/draftlogs/upload_fieldnotes/docs.xml @@ -0,0 +1,47 @@ + + Upload Fieldnotes + ocde-specific + 630 + +

    This method allows you to upload a series of fieldnote objects in CSV format. + Fieldnotes are draft versions of log entries. Once uploaded, users will be able + to review, edit, and submit them via the Opencaching website.

    +

    This method is only available on installations that support draft logs. Check + the has_draft_logs field in + services/apisrv/installation + before calling it.

    +
    + +

    The fieldnotes data. Accepted either as a raw string or base64-encoded + (base64 is recommended when the log text contains newlines or special characters, + and is required when using the OKAPI Browser).

    +

    The data is a sequence of CSV records with no header line. Each record + has four fields:

    +
      +
    1. Geocache Code - e.g. OC1012
    2. +
    3. Date - ISO 8601 UTC, e.g. 2023-11-23T10:17:55Z
    4. +
    5. Log Type - must match a value from the log_types list in + services/apisrv/installation + (case sensitive). Records with unrecognized log types are silently skipped.
    6. +
    7. Log Text - may span multiple lines and contain quote characters.
    8. +
    +

    Character encoding is detected automatically. Supported encodings: UTF-8 + (with or without BOM), UTF-16LE (with or without BOM), UTF-16BE (with BOM). + This covers all known fieldnote sources (cgeo, Garmin, Looking4Cache, etc.).

    +

    Fieldnotes produced by multi-platform apps (e.g. cgeo) typically contain + records for several geocaching platforms at once. Records not starting with + an OC cache code are silently skipped; the difference between + total_records and processed_records in the response reflects this.

    +
    + + +

    A dictionary of the following structure:

    +
      +
    • success - true
    • +
    • total_records - number of CSV records found in the input
    • +
    • processed_records - number of records actually inserted as draft + logs; may be less than total_records when records are skipped (wrong + platform prefix, unsupported log type, unknown cache code, invalid date)
    • +
    +
    +