diff --git a/okapi/core/OkapiServiceRunner.php b/okapi/core/OkapiServiceRunner.php
index a1d0abba..250c0eac 100644
--- a/okapi/core/OkapiServiceRunner.php
+++ b/okapi/core/OkapiServiceRunner.php
@@ -43,6 +43,13 @@ class OkapiServiceRunner
'services/caches/formatters/garmin',
'services/caches/formatters/ggz',
'services/caches/map/tile',
+ 'services/lists/add_caches',
+ 'services/lists/create',
+ 'services/lists/delete',
+ 'services/lists/get_caches',
+ 'services/lists/remove_caches',
+ 'services/lists/query',
+ 'services/lists/update',
'services/logs/capabilities',
'services/logs/delete',
'services/logs/edit',
diff --git a/okapi/services/lists/add_caches/WebService.php b/okapi/services/lists/add_caches/WebService.php
new file mode 100644
index 00000000..5c3894bf
--- /dev/null
+++ b/okapi/services/lists/add_caches/WebService.php
@@ -0,0 +1,67 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false
+ );
+
+ $user_id = $request->token->user_id;
+
+ $listId = $request->get_parameter('list_id');
+ $cacheCodes = $request->get_parameter('cache_codes');
+
+ if (empty($listId)) {
+ throw new InvalidParam('list_id', 'list_id is mandatory and must not be empty.');
+ }
+
+ if (empty($cacheCodes)) {
+ throw new InvalidParam('cache_codes', 'cache_codes is mandatory and must not be empty.');
+ }
+
+ $cacheCodesArray = array_unique(explode('|', $cacheCodes));
+
+ // Check the length
+ if (count($cacheCodesArray) > 500) {
+ throw new InvalidParam('cache_codes', 'The number of cache codes exceeds the limit of 500.');
+ }
+
+ // Escape cache codes and build the SQL query
+ $escapedCacheCodes = implode("','", array_map('\okapi\core\Db::escape_string', $cacheCodesArray));
+
+ // Fetch cache_ids from the caches table using INSERT IGNORE
+ $rs = Db::query("
+ INSERT IGNORE INTO cache_list_items (cache_list_id, cache_id)
+ SELECT '$listId', cache_id
+ FROM caches
+ WHERE wp_oc IN ('$escapedCacheCodes')
+ ");
+
+ $insertedCount = $rs->rowCount(); // Get the number of affected rows
+
+ $result = array(
+ 'success' => true,
+ 'added_count' => $insertedCount
+ );
+
+ return Okapi::formatted_response($request, $result);
+ }
+}
+
diff --git a/okapi/services/lists/add_caches/docs.xml b/okapi/services/lists/add_caches/docs.xml
new file mode 100644
index 00000000..5146b97c
--- /dev/null
+++ b/okapi/services/lists/add_caches/docs.xml
@@ -0,0 +1,23 @@
+
+ Add Caches To List
+ 627
+
+ This method is used to add geocaches to an existing list.
+
+
+ The id of a list. List IDs can be obtained by ::service/lists/query
+
+
+ A pipe separated list of cache_codes to be added to the list.
+ Up to 500 geoaches can be added to the list by one request to
+ this method
+
+
+
+ A dictionary of the following structure:
+
+ - success - true
+ - added_count - number of geocaches added to the list
+
+
+
diff --git a/okapi/services/lists/create/WebService.php b/okapi/services/lists/create/WebService.php
new file mode 100644
index 00000000..9e949d57
--- /dev/null
+++ b/okapi/services/lists/create/WebService.php
@@ -0,0 +1,93 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false
+ );
+
+ if (Settings::get('OC_BRANCH') == 'oc.de')
+ {
+ $user_id = $request->token->user_id;
+
+ $listName = $request->get_parameter('list_name');
+ $listDescription = $request->get_parameter('list_description');
+ $listStatus = $request->get_parameter('list_status');
+ $isWatched = $request->get_parameter('is_watched');
+ $listPassword = $request->get_parameter('list_password');
+
+ if (empty($listName)) {
+ throw new InvalidParam('list_name', 'list_name is mandatory and must not be empty.');
+ }
+
+ $insertFields = array(
+ 'name' => Db::escape_string($listName),
+ 'user_id' => Db::escape_string($user_id)
+ );
+
+ if (!empty($listDescription)) {
+ $insertFields['description'] = Db::escape_string($listDescription);
+ }
+
+ if ($listStatus !== null && $listStatus !== '') {
+ $listStatus = (int)$listStatus;
+ if (!in_array($listStatus, [0, 2, 3])) {
+ throw new InvalidParam('list_status', 'list_status must be a valid value (0, 2, 3).');
+ }
+ $insertFields['is_public'] = $listStatus;
+
+ // Handle list_password only if list_status is 0 (private)
+ if ($listStatus == 0) {
+ if (isset($listPassword) && $listPassword !== '') {
+ $insertFields['password'] = substr(Db::escape_string($listPassword), 0, 16);
+ }
+ }
+ }
+
+ $columns = implode(', ', array_keys($insertFields));
+ $values = "'" . implode("', '", $insertFields) . "'";
+
+ $insertQuery = "INSERT INTO cache_lists ($columns) VALUES ($values)";
+ Db::query($insertQuery);
+
+ $listId = Db::last_insert_id();
+
+ // Handle is_watched
+ if ($isWatched !== null && $isWatched !== '') {
+ $isWatched = (int)$isWatched;
+ if (!in_array($isWatched, [0, 1])) {
+ throw new InvalidParam('is_watched', 'is_watched must be a valid value (0, 1).');
+ }
+
+ // Insert a new record
+ Db::query("INSERT INTO cache_list_watches (cache_list_id, user_id, is_watched) VALUES (LAST_INSERT_ID(), '$user_id', $isWatched)");
+ }
+
+ $result = array(
+ 'success' => true,
+ 'message' => 'Cache list created successfully.',
+ 'list_id' => $listId
+ );
+ }
+ return Okapi::formatted_response($request, $result);
+ }
+}
+
diff --git a/okapi/services/lists/create/docs.xml b/okapi/services/lists/create/docs.xml
new file mode 100644
index 00000000..f8d9fd05
--- /dev/null
+++ b/okapi/services/lists/create/docs.xml
@@ -0,0 +1,46 @@
+
+ Create Cache List
+ 627
+
+ This method creates a list for adding geocaches to. Only the list is created,
+ no geocaches are added to it during the create. For adding and removing geocaches
+ to/from the list use specific methods within the ::services/lists namespace
+
+
+
+ A string, defining the human readable name of the list
+
+
+ A string via which a description of the list's purpose and
+ potentially content can be defined.
+
+
+ This parameter can take the following values:
+
+ - 0 - The list is private
+ - 2 - The list is public
+ - 3 - The list is public and is visible in cache listings
+
+
+
+
+ This parameter allows to add a password to a private list. The password
+ has no meaning if the list is public. The first 16 alphanumeric characters
+ are used as a password. No encryption is performed in the password, instead
+ it is stored in the database in plain text.
+
+
+ A boolean, either 0, 1, false, true. If set to 1 or true
+ a user wants to get a notification if the content of the list
+ changes.
+
+
+
+ A dictionary of the following structure:
+
+ - success - true
+ - message - Cache list created successfully
+ - list_id - The id of the list by which it can be referenced
+
+
+
diff --git a/okapi/services/lists/delete/WebService.php b/okapi/services/lists/delete/WebService.php
new file mode 100644
index 00000000..89dd1ea8
--- /dev/null
+++ b/okapi/services/lists/delete/WebService.php
@@ -0,0 +1,57 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false
+ );
+
+ if (Settings::get('OC_BRANCH') == 'oc.de')
+ {
+ $user_id = $request->token->user_id;
+
+ $listId = $request->get_parameter('list_id');
+
+ if (empty($listId) || !is_numeric($listId)) {
+ throw new InvalidParam('list_id', 'list_id is mandatory and must be numeric.');
+ }
+
+ // Check if the list exists
+ $countQuery = Db::query("SELECT COUNT(*) AS count FROM cache_lists WHERE id = '$listId' AND user_id = '$user_id'");
+ $listExists = Db::fetch_assoc($countQuery)['count'];
+ if ($listExists == 0) {
+ throw new InvalidParam('list_id', 'The specified list does not exist.');
+ }
+
+ // Proceed with the deletion process
+ Db::query("DELETE FROM cache_lists WHERE id = '$listId'");
+ Db::query("DELETE FROM cache_list_watches WHERE cache_list_id = '$listId'");
+ Db::query("DELETE FROM cache_list_items WHERE cache_list_id = '$listId'");
+
+ $result = array(
+ 'success' => true,
+ 'message' => 'Cache list deleted successfully.'
+ );
+ }
+ return Okapi::formatted_response($request, $result);
+ }
+}
+
diff --git a/okapi/services/lists/delete/docs.xml b/okapi/services/lists/delete/docs.xml
new file mode 100644
index 00000000..f6d218cc
--- /dev/null
+++ b/okapi/services/lists/delete/docs.xml
@@ -0,0 +1,20 @@
+
+ Delete Cache List
+ 627
+
+ This method is used to delete a geocache list. The geocache objects
+ that were on the list will not be touched in any way.
+
+
+ The id of the list to be removed. IDs can be obtained by
+ the service ::services/lists/query
+
+
+
+ A dictionary of the following structure:
+
+ - success - true
+ - message - cache list deleted successfully
+
+
+
diff --git a/okapi/services/lists/get_caches/WebService.php b/okapi/services/lists/get_caches/WebService.php
new file mode 100644
index 00000000..b5e6e751
--- /dev/null
+++ b/okapi/services/lists/get_caches/WebService.php
@@ -0,0 +1,61 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false
+ );
+
+ $user_id = $request->token->user_id;
+ $listId = $request->get_parameter('list_id');
+
+ if (empty($listId)) {
+ throw new InvalidParam('list_id', 'list_id is mandatory and must not be empty.');
+ }
+
+ // Fetch cache_ids associated with the specified list
+ $cacheIdsArray = Db::select_column("
+ SELECT cache_id
+ FROM cache_list_items
+ WHERE cache_list_id = '$listId'
+ ");
+
+ $cacheCount = count($cacheIdsArray);
+
+ // Fetch cache_codes based on cache_ids
+ $cacheCodesArray = array();
+
+ if (!empty($cacheIdsArray)) {
+ $cacheIds = implode(',', $cacheIdsArray);
+ $cacheCodesArray = Db::select_column(
+ "SELECT wp_oc FROM caches WHERE cache_id IN ($cacheIds)"
+ );
+ }
+
+ $result = array(
+ 'success' => true,
+ 'cache_codes' => implode('|', $cacheCodesArray),
+ 'cache_count' => $cacheCount
+ );
+
+ return Okapi::formatted_response($request, $result);
+ }
+}
+
diff --git a/okapi/services/lists/get_caches/docs.xml b/okapi/services/lists/get_caches/docs.xml
new file mode 100644
index 00000000..c6885d8a
--- /dev/null
+++ b/okapi/services/lists/get_caches/docs.xml
@@ -0,0 +1,19 @@
+
+ Get Caches On A List
+ 627
+
+ This method is used to get the geocache codes for the caches that are on the list.
+
+
+ The id of a list. List IDs can be obtained by ::service/lists/query
+
+
+
+ A dictionary of the following structure:
+
+ - success - true
+ - cache_codes - A pipe separated string of cache_codes that are on the list
+ - cache_count - the number of geocaches on the list
+
+
+
diff --git a/okapi/services/lists/query/WebService.php b/okapi/services/lists/query/WebService.php
new file mode 100644
index 00000000..f0f3e490
--- /dev/null
+++ b/okapi/services/lists/query/WebService.php
@@ -0,0 +1,70 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false // if the installation doesn't support it
+ );
+
+ if (Settings::get('OC_BRANCH') == 'oc.de')
+ {
+ $user_id = $request->token->user_id;
+ $rs = Db::query("
+ SELECT
+ id,
+ name,
+ date_created,
+ last_modified,
+ last_added,
+ description,
+ is_public,
+ (
+ SELECT COUNT(*)
+ FROM cache_list_items
+ WHERE cache_list_id = cache_lists.id
+ ) AS caches_count,
+ (
+ SELECT COUNT(*)
+ FROM cache_list_watches
+ WHERE cache_list_id = cache_lists.id
+ ) AS watches_count
+ FROM cache_lists
+ WHERE user_id = '".Db::escape_string($user_id)."'
+ ");
+
+ $lists = [];
+ while ($list = Db::fetch_assoc($rs))
+ {
+ $lists[] = $list;
+ }
+
+ $result = json_encode($lists, JSON_PRETTY_PRINT);
+ }
+ return Okapi::formatted_response($request, $result);
+ }
+
+
+ // ------------------------------------------------------------------
+
+}
diff --git a/okapi/services/lists/query/docs.xml b/okapi/services/lists/query/docs.xml
new file mode 100644
index 00000000..8c3d0c71
--- /dev/null
+++ b/okapi/services/lists/query/docs.xml
@@ -0,0 +1,40 @@
+
+ Query Cache Lists
+ 627
+
+ This method is used to query the metadata of all user owned cache lists.
+ Such a query ist typically performed by a client application that wants to render
+ a list of geocache lists at the UI. The metadata of the list is necessary and sufficient
+ for this purpose. Please note, an id is also part of the returned meta data.
+ Using this id as a reference code, specific operations can be performed on selected lists
+ for instance adding geocaches to the list, removing them, updating the metadata of the
+ referenced list or deleting the referenced list entirely. Typically such entities as
+ geocaches, logs, or lists, are referenced by a referenceCode, for instance
+ for geocaches, there would be a cache_code in the namespace OCxxxxx where
+ cache_code would be interpreted as a referenceCode for geocaches. Unfortunately lists
+ do not yet have such a referenceCode. They can be referenced only by their internal id,
+ which is named id in the returned objects for this method. This id is used as the
+ list_id parameter in all subsequent methods dealing with lists.
+
+
+
+
+ An array of the following structure:
+
+[
+ {
+ "id": 13,
+ "name": "This is my list",
+ "date_created": "2023-11-28 09:29:34",
+ "last_modified": "2023-11-28 09:49:16",
+ "last_added": "2023-11-28 09:40:18",
+ "description": "Just a list for testing",
+ "is_public": 0,
+ "caches_count": 4,
+ "watches_count": 1
+ },
+ { .... }
+]
+
+
+
diff --git a/okapi/services/lists/remove_caches/WebService.php b/okapi/services/lists/remove_caches/WebService.php
new file mode 100644
index 00000000..1ff393c4
--- /dev/null
+++ b/okapi/services/lists/remove_caches/WebService.php
@@ -0,0 +1,74 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false
+ );
+
+ $user_id = $request->token->user_id;
+
+ $listId = $request->get_parameter('list_id');
+ $cacheCodes = $request->get_parameter('cache_codes');
+
+ if (empty($listId)) {
+ throw new InvalidParam('list_id', 'list_id is mandatory and must not be empty.');
+ }
+
+ if (empty($cacheCodes)) {
+ throw new InvalidParam('cache_codes', 'cache_codes is mandatory and must not be empty.');
+ }
+
+ $cacheCodesArray = array_unique(explode('|', $cacheCodes));
+
+ // Check the length
+ if (count($cacheCodesArray) > 500) {
+ throw new InvalidParam('cache_codes', 'The number of cache codes exceeds the limit of 500.');
+ }
+
+ // Escape cache codes and build the SQL query
+ $escapedCacheCodes = implode("','", array_map('\okapi\core\Db::escape_string', $cacheCodesArray));
+
+ // Delete cache_ids from the cache_list_items table
+ $rs = Db::query("
+ DELETE FROM cache_list_items
+ WHERE cache_list_id = '$listId'
+ AND cache_id IN (
+ SELECT cache_id
+ FROM caches
+ WHERE wp_oc IN ('$escapedCacheCodes')
+ )
+ ");
+
+ $removedCount = $rs->rowCount(); // Get the number of affected rows
+
+ $result = array(
+ 'success' => true,
+ 'removed_count' => $removedCount
+ );
+
+ return Okapi::formatted_response($request, $result);
+ }
+}
+
diff --git a/okapi/services/lists/remove_caches/docs.xml b/okapi/services/lists/remove_caches/docs.xml
new file mode 100644
index 00000000..bbb1de4c
--- /dev/null
+++ b/okapi/services/lists/remove_caches/docs.xml
@@ -0,0 +1,23 @@
+
+ Remove Caches From List
+ 627
+
+ This method removes geocaches from a list.
+
+
+ The id of a list. List IDs can be obtained by ::service/lists/query
+
+
+ A pipe separated list of cache_codes to be removed to the list.
+ Up to 500 geoaches can be removed from the list by one request to
+ this method
+
+
+
+ A dictionary of the following structure:
+
+ - success - true
+ - removed_count - number of geocaches removed from the list
+
+
+
diff --git a/okapi/services/lists/update/WebService.php b/okapi/services/lists/update/WebService.php
new file mode 100644
index 00000000..0330abcb
--- /dev/null
+++ b/okapi/services/lists/update/WebService.php
@@ -0,0 +1,117 @@
+ 3
+ );
+ }
+
+ public static function call(OkapiRequest $request)
+ {
+ $result = array(
+ 'success' => false
+ );
+
+ if (Settings::get('OC_BRANCH') == 'oc.de')
+ {
+ $user_id = $request->token->user_id;
+
+ $listId = $request->get_parameter('list_id');
+ $listName = $request->get_parameter('list_name');
+ $listDescription = $request->get_parameter('list_description');
+ $listStatus = $request->get_parameter('list_status');
+ $listWatch = $request->get_parameter('list_watch');
+ $listPassword = $request->get_parameter('list_password');
+
+ if (empty($listId) || !is_numeric($listId)) {
+ throw new InvalidParam('list_id', 'list_id is mandatory and must be numeric.');
+ }
+
+ if (empty($listName) && empty($listDescription) && ($listStatus === null || $listStatus === '') && ($listWatch === null || $listWatch === '') && ($listPassword === null || $listPassword === '')) {
+ throw new InvalidParam('list_name, list_description, list_status, list_watch, list_password', 'At least one optional parameter is required.');
+ }
+
+ $updateFields = array();
+
+ if (!empty($listName)) {
+ $updateFields['name'] = Db::escape_string($listName);
+ }
+
+ if (!empty($listDescription)) {
+ $updateFields['description'] = Db::escape_string($listDescription);
+ }
+
+ if ($listStatus !== null && $listStatus !== '') {
+ $listStatus = (int)$listStatus;
+ if (!in_array($listStatus, [0, 2, 3])) {
+ throw new InvalidParam('list_status', 'list_status must be a valid value (0, 2, 3).');
+ }
+ $updateFields['is_public'] = $listStatus;
+
+ // Handle list_password only if list_status is 0 (private)
+ if ($listStatus == 0) {
+ if (isset($listPassword) && $listPassword !== '') {
+ $updateFields['password'] = substr(Db::escape_string($listPassword), 0, 16);
+ } else {
+ $updateFields['password'] = null; // Remove the password
+ }
+ }
+ }
+
+ if ($listWatch !== null && $listWatch !== '') {
+ $listWatch = (int)$listWatch;
+ $currentWatchState = (int) Db::query("
+ SELECT COUNT(*)
+ FROM cache_list_watches
+ WHERE cache_list_id = '" . Db::escape_string($listId) . "'
+ AND user_id = '" . Db::escape_string($user_id) . "'
+ ")->fetchColumn();
+
+ if ($listWatch == 1 && $currentWatchState == 0) {
+ // Watched and not in cache_list_watches, insert
+ Db::query("
+ INSERT INTO cache_list_watches (cache_list_id, user_id)
+ VALUES ('" . Db::escape_string($listId) . "', '" . Db::escape_string($user_id) . "')
+ ");
+ } elseif ($listWatch == 0 && $currentWatchState > 0) {
+ // Unwatched and in cache_list_watches, delete
+ Db::query("
+ DELETE FROM cache_list_watches
+ WHERE cache_list_id = '" . Db::escape_string($listId) . "'
+ AND user_id = '" . Db::escape_string($user_id) . "'
+ ");
+ }
+ }
+
+ if (!empty($updateFields)) {
+ $updateQuery = "UPDATE cache_lists SET ";
+ $updateQuery .= implode(', ', array_map(function ($field, $value) {
+ return "$field = '$value'";
+ }, array_keys($updateFields), $updateFields));
+ $updateQuery .= " WHERE id = '" . Db::escape_string($listId) . "'";
+
+ Db::query($updateQuery);
+ }
+
+ $result = array(
+ 'success' => true,
+ 'message' => 'Cache list updated successfully.'
+ );
+ }
+ return Okapi::formatted_response($request, $result);
+ }
+}
+
diff --git a/okapi/services/lists/update/docs.xml b/okapi/services/lists/update/docs.xml
new file mode 100644
index 00000000..34c1e10e
--- /dev/null
+++ b/okapi/services/lists/update/docs.xml
@@ -0,0 +1,50 @@
+
+ Update Cache List
+ 627
+
+ This method is is used to update the meta data of an existing cache list.
+
+
+ The list_id uniquely references the
+ list for which the metadata should be updated. The list_id can be obtained
+ by ::services/lists/query
+
+
+ The new name of the list. The current name can be obtained by
+ ::services/lists/query
+
+
+ The new description of the list. The current description can be obtained by
+ ::services/lists/query
+
+
+ The new status of the list. The current status can be obtained by
+ ::services/lists/query
+ Status is defined as follows:
+
+ - 0 - indicates a private list
+ - 2 - indicates a public list
+ - 3 - indicates a public list, visible for all users in cache listings
+
+
+
+
+ The password for the list. Passwords only have a meaning if the list status is set to
+ private. The parameter is silently ignored otherwise. If the list is private, the first
+ 16 alphanumeric charactes are taken as a password. No encryption is performed on the
+ password, instead it is stored as plain text in the database.
+
+
+ A boolean, either 0, 1, false, true. If set to 1 or true
+ a user wants to get a notification if the content of the list
+ changes.
+
+
+
+ A dictionary of the following structure:
+
+ - success - true
+ - message - Cache list updated sucessfully
+
+
+