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:
+
+
+
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:
+
+ - Geocache Code - e.g. OC1012
+ - Date - ISO 8601 UTC, e.g. 2023-11-23T10:17:55Z
+ - 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.
+ - Log Text - may span multiple lines and contain quote characters.
+
+ 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)
+
+
+