From 2b05cef5c7f3041444ad415935f482e8b3cacfee Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Samuli=20Sillanp=C3=A4=C3=A4?=
 <samuli.sillanpaa@helsinki.fi>
Date: Fri, 14 Aug 2020 16:29:11 -0400
Subject: [PATCH] Add support for tagging of user lists. - Deprecates
 VuFind\Db\Row\UserList::getTags() in favor of
 VuFind\Db\Row\UserList::getResourceTags() - Deprecates
 VuFind\Db\Table\ResourceTags::destroyLinks() in favor of
 VuFind\Db\Table\ResourceTags::destroyResourceLinks() - Includes code
 originally developed as part of pull request #1645.

---
 config/vufind/config.ini                      |  2 +
 .../pgsql/7.1/001-allow-list-tags.sql         |  9 ++
 module/VuFind/sql/mysql.sql                   |  2 +-
 module/VuFind/sql/pgsql.sql                   |  2 +-
 .../src/VuFind/Config/AccountCapabilities.php | 13 +++
 .../VuFind/Controller/BrowseController.php    |  7 +-
 .../Controller/MyResearchController.php       | 38 ++++++++-
 module/VuFind/src/VuFind/Db/Row/Resource.php  |  4 +-
 module/VuFind/src/VuFind/Db/Row/User.php      | 40 +++++++--
 module/VuFind/src/VuFind/Db/Row/UserList.php  | 85 +++++++++++++++++--
 .../src/VuFind/Db/Row/UserListFactory.php     |  5 +-
 .../src/VuFind/Db/Table/ResourceTags.php      | 69 +++++++++++++--
 module/VuFind/src/VuFind/Db/Table/Tags.php    | 38 +++++++++
 .../src/VuFind/Db/Table/UserResource.php      |  4 +-
 .../src/VuFind/View/Helper/Root/UserTags.php  | 23 ++++-
 .../View/Helper/Root/UserTagsFactory.php      |  5 +-
 .../VuFind/src/VuFindTest/Unit/DbTestCase.php |  1 +
 .../templates/myresearch/editlist.phtml       |  7 ++
 .../templates/myresearch/mylist.phtml         |  3 +
 19 files changed, 325 insertions(+), 32 deletions(-)
 create mode 100644 module/VuFind/sql/migrations/pgsql/7.1/001-allow-list-tags.sql

diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index c8c236fac83..c68fd5e0184 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -1984,6 +1984,8 @@ lists_view=full
 ; Tags may be "enabled" or "disabled" (default = "enabled")
 ; When disabling tags, don't forget to also turn off tag search in searches.ini.
 tags = enabled
+; User list tags may be "enabled" or "disabled" (default = "disabled")
+listTags = disabled
 ; This controls the maximum length of a single tag; it should correspond with the
 ; field size in the tags database table.
 max_tag_length = 64
diff --git a/module/VuFind/sql/migrations/pgsql/7.1/001-allow-list-tags.sql b/module/VuFind/sql/migrations/pgsql/7.1/001-allow-list-tags.sql
new file mode 100644
index 00000000000..de562b4176c
--- /dev/null
+++ b/module/VuFind/sql/migrations/pgsql/7.1/001-allow-list-tags.sql
@@ -0,0 +1,9 @@
+--
+-- Modifications to table `resource_tags`
+--
+
+ALTER TABLE "resource_tags"
+   ALTER COLUMN resource_id DROP NOT NULL;
+
+ALTER TABLE "resource_tags"
+   ALTER COLUMN resource_id SET DEFAULT NULL;
diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql
index 5f3ff8cb8c0..95663adb92e 100644
--- a/module/VuFind/sql/mysql.sql
+++ b/module/VuFind/sql/mysql.sql
@@ -88,7 +88,7 @@ CREATE TABLE `resource` (
 /*!40101 SET character_set_client = utf8 */;
 CREATE TABLE `resource_tags` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
-  `resource_id` int(11) NOT NULL DEFAULT '0',
+  `resource_id` int(11) DEFAULT NULL,
   `tag_id` int(11) NOT NULL DEFAULT '0',
   `list_id` int(11) DEFAULT NULL,
   `user_id` int(11) DEFAULT NULL,
diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql
index e2c22278141..989f7f45d65 100644
--- a/module/VuFind/sql/pgsql.sql
+++ b/module/VuFind/sql/pgsql.sql
@@ -47,7 +47,7 @@ DROP TABLE IF EXISTS "resource_tags";
 
 CREATE TABLE resource_tags (
 id SERIAL,
-resource_id int NOT NULL DEFAULT '0',
+resource_id int DEFAULT NULL,
 tag_id int NOT NULL DEFAULT '0',
 list_id int DEFAULT NULL,
 user_id int DEFAULT NULL,
diff --git a/module/VuFind/src/VuFind/Config/AccountCapabilities.php b/module/VuFind/src/VuFind/Config/AccountCapabilities.php
index 1c559b08812..08cc66117e4 100644
--- a/module/VuFind/src/VuFind/Config/AccountCapabilities.php
+++ b/module/VuFind/src/VuFind/Config/AccountCapabilities.php
@@ -136,6 +136,19 @@ class AccountCapabilities
             ? 'disabled' : 'enabled';
     }
 
+    /**
+     * Get list tag setting.
+     *
+     * @return string
+     */
+    public function getListTagSetting()
+    {
+        if (!$this->isAccountAvailable()) {
+            return 'disabled';
+        }
+        return $this->config->Social->listTags ?? 'disabled';
+    }
+
     /**
      * Get SMS setting ('enabled' or 'disabled').
      *
diff --git a/module/VuFind/src/VuFind/Controller/BrowseController.php b/module/VuFind/src/VuFind/Controller/BrowseController.php
index e609c487d9c..bba2d063936 100644
--- a/module/VuFind/src/VuFind/Controller/BrowseController.php
+++ b/module/VuFind/src/VuFind/Controller/BrowseController.php
@@ -345,9 +345,14 @@ class BrowseController extends AbstractBase
                 }
             } else {
                 // Default case: always display tag list for non-alphabetical modes:
+                $callback = function ($select) {
+                    // Discard user list tags
+                    $select->where->isNotNull('resource_tags.resource_id');
+                };
+
                 $tagList = $tagTable->getTagList(
                     $params['findby'],
-                    $this->config->Browse->result_limit
+                    $this->config->Browse->result_limit, $callback
                 );
                 $resultList = [];
                 foreach ($tagList as $i => $tag) {
diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php
index 0933d76e98b..60555e02d45 100644
--- a/module/VuFind/src/VuFind/Controller/MyResearchController.php
+++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php
@@ -909,8 +909,20 @@ class MyResearchController extends AbstractBase
             };
 
             $results = $runner->run($request, 'Favorites', $setupCallback);
+            $listTags = [];
+
+            if ($this->listTagsEnabled()) {
+                if ($list = $results->getListObject()) {
+                    foreach ($list->getListTags() as $tag) {
+                        $listTags[$tag->id] = $tag->tag;
+                    }
+                }
+            }
             return $this->createViewModel(
-                ['params' => $results->getParams(), 'results' => $results]
+                [
+                    'params' => $results->getParams(), 'results' => $results,
+                    'listTags' => $listTags
+                ]
             );
         } catch (ListPermissionException $e) {
             if (!$this->getUser()) {
@@ -1019,8 +1031,18 @@ class MyResearchController extends AbstractBase
             }
         }
 
+        $listTags = null;
+        if ($this->listTagsEnabled() && !$newList) {
+            $listTags = $user->formatTagString($list->getListTags());
+        }
         // Send the list to the view:
-        return $this->createViewModel(['list' => $list, 'newList' => $newList]);
+        return $this->createViewModel(
+            [
+                'list' => $list,
+                'newList' => $newList,
+                'listTags' => $listTags
+            ]
+        );
     }
 
     /**
@@ -2129,4 +2151,16 @@ class MyResearchController extends AbstractBase
         }
         return $this->paginationHelper;
     }
+
+    /**
+     * Are list tags enabled?
+     *
+     * @return bool
+     */
+    protected function listTagsEnabled()
+    {
+        $check = $this->serviceLocator
+            ->get(\VuFind\Config\AccountCapabilities::class);
+        return $check->getListTagSetting() === 'enabled';
+    }
 }
diff --git a/module/VuFind/src/VuFind/Db/Row/Resource.php b/module/VuFind/src/VuFind/Db/Row/Resource.php
index 86824d23040..8cf00c60a08 100644
--- a/module/VuFind/src/VuFind/Db/Row/Resource.php
+++ b/module/VuFind/src/VuFind/Db/Row/Resource.php
@@ -65,7 +65,7 @@ class Resource extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
     public function deleteTags($user, $list_id = null)
     {
         $unlinker = $this->getDbTable('ResourceTags');
-        $unlinker->destroyLinks($this->id, $user->id, $list_id);
+        $unlinker->destroyResourceLinks($this->id, $user->id, $list_id);
     }
 
     /**
@@ -113,7 +113,7 @@ class Resource extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
             }
             if (!empty($tagIds)) {
                 $linker = $this->getDbTable('ResourceTags');
-                $linker->destroyLinks(
+                $linker->destroyResourceLinks(
                     $this->id, $user->id, $list_id, $tagIds
                 );
             }
diff --git a/module/VuFind/src/VuFind/Db/Row/User.php b/module/VuFind/src/VuFind/Db/Row/User.php
index 73a4372f8ee..5b2ddd8700a 100644
--- a/module/VuFind/src/VuFind/Db/Row/User.php
+++ b/module/VuFind/src/VuFind/Db/Row/User.php
@@ -274,6 +274,19 @@ class User extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterface,
             ->getForUser($this->id, $resourceId, $listId, $source);
     }
 
+    /**
+     * Get tags assigned by the user to a favorite list.
+     *
+     * @param int $listId List id
+     *
+     * @return \Laminas\Db\ResultSet\AbstractResultSet
+     */
+    public function getListTags($listId)
+    {
+        return $this->getDbTable('Tags')
+            ->getForList($listId, $this->id);
+    }
+
     /**
      * Same as getTags(), but returns a string for use in edit mode rather than an
      * array of tag objects.
@@ -289,14 +302,25 @@ class User extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterface,
      */
     public function getTagString($resourceId = null, $listId = null, $source = null)
     {
-        $myTagList = $this->getTags($resourceId, $listId, $source);
+        return $this->formatTagString($this->getTags($resourceId, $listId, $source));
+    }
+
+    /**
+     * Same as getTagString(), but operates on a list of tags.
+     *
+     * @param array $tags Tags
+     *
+     * @return string
+     */
+    public function formatTagString($tags)
+    {
         $tagStr = '';
-        if (count($myTagList) > 0) {
-            foreach ($myTagList as $myTag) {
-                if (strstr($myTag->tag, ' ')) {
-                    $tagStr .= "\"$myTag->tag\" ";
+        if (count($tags) > 0) {
+            foreach ($tags as $tag) {
+                if (strstr($tag->tag, ' ')) {
+                    $tagStr .= "\"$tag->tag\" ";
                 } else {
-                    $tagStr .= "$myTag->tag ";
+                    $tagStr .= "$tag->tag ";
                 }
             }
         }
@@ -412,7 +436,7 @@ class User extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterface,
         // Remove Resource (related tags are also removed implicitly)
         $userResourceTable = $this->getDbTable('UserResource');
         // true here makes sure that only tags in lists are deleted
-        $userResourceTable->destroyLinks($resourceIDs, $this->id, true);
+        $userResourceTable->destroyResourceLinks($resourceIDs, $this->id, true);
     }
 
     /**
@@ -656,7 +680,7 @@ class User extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterface,
             $list->delete($this, true);
         }
         $resourceTags = $this->getDbTable('ResourceTags');
-        $resourceTags->destroyLinks(null, $this->id);
+        $resourceTags->destroyResourceLinks(null, $this->id);
         if ($removeComments) {
             $comments = $this->getDbTable('Comments');
             $comments->deleteByUser($this);
diff --git a/module/VuFind/src/VuFind/Db/Row/UserList.php b/module/VuFind/src/VuFind/Db/Row/UserList.php
index 508ef3b2669..8a3c8b29c92 100644
--- a/module/VuFind/src/VuFind/Db/Row/UserList.php
+++ b/module/VuFind/src/VuFind/Db/Row/UserList.php
@@ -30,6 +30,7 @@ namespace VuFind\Db\Row;
 use Laminas\Session\Container;
 use VuFind\Exception\ListPermission as ListPermissionException;
 use VuFind\Exception\MissingField as MissingFieldException;
+use VuFind\Tags;
 
 /**
  * Row Definition for user_list
@@ -51,14 +52,23 @@ class UserList extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
      */
     protected $session = null;
 
+    /**
+     * Tag parser.
+     *
+     * @var Tags
+     */
+    protected $tagParser;
+
     /**
      * Constructor
      *
-     * @param \Laminas\Db\Adapter\Adapter $adapter Database adapter
-     * @param Container                   $session Session container
+     * @param \Laminas\Db\Adapter\Adapter $adapter   Database adapter
+     * @param Tags                        $tagParser Tag parser
+     * @param Container                   $session   Session container
      */
-    public function __construct($adapter, Container $session = null)
+    public function __construct($adapter, Tags $tagParser, Container $session = null)
     {
+        $this->tagParser = $tagParser;
         $this->session = $session;
         parent::__construct('id', 'user_list', $adapter);
     }
@@ -79,11 +89,11 @@ class UserList extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
     }
 
     /**
-     * Get an array of tags associated with this list.
+     * Get an array of resource tags associated with this list.
      *
      * @return array
      */
-    public function getTags()
+    public function getResourceTags()
     {
         $table = $this->getDbTable('User');
         $user = $table->select(['id' => $this->user_id])->current();
@@ -93,6 +103,33 @@ class UserList extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
         return $user->getTags(null, $this->id);
     }
 
+    /**
+     * Get an array of resource tags associated with this list.
+     *
+     * @deprecated Deprecated, use getResourceTags.
+     *
+     * @return array
+     */
+    public function getTags()
+    {
+        return $this->getResourceTags();
+    }
+
+    /**
+     * Get an array of tags assigned to this list.
+     *
+     * @return array
+     */
+    public function getListTags()
+    {
+        $table = $this->getDbTable('User');
+        $user = $table->select(['id' => $this->user_id])->current();
+        if (empty($user)) {
+            return [];
+        }
+        return $user->getListTags($this->id, $this->user_id);
+    }
+
     /**
      * Update and save the list object using a request object -- useful for
      * sharing form processing between multiple actions.
@@ -110,9 +147,39 @@ class UserList extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
         $this->description = $request->get('desc');
         $this->public = $request->get('public');
         $this->save($user);
+
+        if (null !== ($tags = $request->get('tags'))) {
+            $linker = $this->getDbTable('resourcetags');
+            $linker->destroyListLinks($this->id, $user->id);
+            foreach ($this->tagParser->parse($tags) as $tag) {
+                $this->addListTag($tag, $user);
+            }
+        }
+
         return $this->id;
     }
 
+    /**
+     * Add a tag to the list.
+     *
+     * @param string              $tagText The tag to save.
+     * @param \VuFind\Db\Row\User $user    The user posting the tag.
+     *
+     * @return void
+     */
+    public function addListTag($tagText, $user)
+    {
+        $tagText = trim($tagText);
+        if (!empty($tagText)) {
+            $tags = $this->getDbTable('tags');
+            $tag = $tags->getByText($tagText);
+            $linker = $this->getDbTable('resourcetags');
+            $linker->createLink(
+                null, $tag->id, $user->id, $this->id
+            );
+        }
+    }
+
     /**
      * Saves the properties to the database.
      *
@@ -192,7 +259,9 @@ class UserList extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
 
         // Remove Resource (related tags are also removed implicitly)
         $userResourceTable = $this->getDbTable('UserResource');
-        $userResourceTable->destroyLinks($resourceIDs, $this->user_id, $this->id);
+        $userResourceTable->destroyResourceLinks(
+            $resourceIDs, $this->user_id, $this->id
+        );
     }
 
     /**
@@ -224,6 +293,10 @@ class UserList extends RowGateway implements \VuFind\Db\Table\DbTableAwareInterf
         $userResource = $this->getDbTable('UserResource');
         $userResource->destroyLinks(null, $this->user_id, $this->id);
 
+        // Remove resource_tags rows for list tags:
+        $linker = $this->getDbTable('resourcetags');
+        $linker->destroyListLinks($this->id, $user->id);
+
         // Remove the list itself:
         return parent::delete();
     }
diff --git a/module/VuFind/src/VuFind/Db/Row/UserListFactory.php b/module/VuFind/src/VuFind/Db/Row/UserListFactory.php
index 949287cce08..31eb9401860 100644
--- a/module/VuFind/src/VuFind/Db/Row/UserListFactory.php
+++ b/module/VuFind/src/VuFind/Db/Row/UserListFactory.php
@@ -62,6 +62,9 @@ class UserListFactory extends RowGatewayFactory
         }
         $sessionManager = $container->get(\Laminas\Session\SessionManager::class);
         $session = new \Laminas\Session\Container('List', $sessionManager);
-        return parent::__invoke($container, $requestedName, [$session]);
+        return parent::__invoke(
+            $container, $requestedName,
+            [$container->get(\VuFind\Tags::class), $session]
+        );
     }
 }
diff --git a/module/VuFind/src/VuFind/Db/Table/ResourceTags.php b/module/VuFind/src/VuFind/Db/Table/ResourceTags.php
index f886361a2bb..a0df30a8695 100644
--- a/module/VuFind/src/VuFind/Db/Table/ResourceTags.php
+++ b/module/VuFind/src/VuFind/Db/Table/ResourceTags.php
@@ -232,15 +232,12 @@ class ResourceTags extends Gateway
      *
      * @return void
      */
-    public function destroyLinks($resource, $user, $list = null, $tag = null)
+    public function destroyResourceLinks($resource, $user, $list = null, $tag = null)
     {
         $callback = function ($select) use ($resource, $user, $list, $tag) {
             $select->where->equalTo('user_id', $user);
             if (null !== $resource) {
-                if (!is_array($resource)) {
-                    $resource = [$resource];
-                }
-                $select->where->in('resource_id', $resource);
+                $select->where->in('resource_id', (array)$resource);
             }
             if (null !== $list) {
                 if (true === $list) {
@@ -263,7 +260,69 @@ class ResourceTags extends Gateway
                 }
             }
         };
+        $this->processDestroyLinks($callback);
+    }
+
+    /**
+     * Unlink rows for the specified resource.
+     *
+     * @param string|array $resource ID (or array of IDs) of resource(s) to
+     * unlink (null for ALL matching resources)
+     * @param string       $user     ID of user removing links
+     * @param string       $list     ID of list to unlink (null for ALL matching
+     * tags, 'none' for tags not in a list, true for tags only found in a list)
+     * @param string|array $tag      ID or array of IDs of tag(s) to unlink (null
+     * for ALL matching tags)
+     *
+     * @deprecated Deprecated, use destroyResourceLinks.
+     *
+     * @return void
+     */
+    public function destroyLinks($resource, $user, $list = null, $tag = null)
+    {
+        $this->destroyResourceLinks($resource, $user, $list, $tag);
+    }
+
+    /**
+     * Unlink rows for the specified user list.
+     *
+     * @param string       $list ID of list to unlink
+     * @param string       $user ID of user removing links
+     * @param string|array $tag  ID or array of IDs of tag(s) to unlink (null
+     * for ALL matching tags)
+     *
+     * @return void
+     */
+    public function destroyListLinks($list, $user, $tag = null)
+    {
+        $callback = function ($select) use ($user, $list, $tag) {
+            $select->where->equalTo('user_id', $user);
+            // retrieve tags assigned to a user list
+            // and filter out user resource tags
+            // (resource_id is NULL for list tags).
+            $select->where->isNull('resource_id');
+            $select->where->equalTo('list_id', $list);
+
+            if (null !== $tag) {
+                if (is_array($tag)) {
+                    $select->where->in('tag_id', $tag);
+                } else {
+                    $select->where->equalTo('tag_id', $tag);
+                }
+            }
+        };
+        $this->processDestroyLinks($callback);
+    }
 
+    /**
+     * Process link rows marked to be destroyed.
+     *
+     * @param Object $callback Callback function for selecting deleted rows.
+     *
+     * @return void
+     */
+    protected function processDestroyLinks($callback)
+    {
         // Get a list of all tag IDs being deleted; we'll use these for
         // orphan-checking:
         $potentialOrphans = $this->select($callback);
diff --git a/module/VuFind/src/VuFind/Db/Table/Tags.php b/module/VuFind/src/VuFind/Db/Table/Tags.php
index b6b647e7f78..cb64f486518 100644
--- a/module/VuFind/src/VuFind/Db/Table/Tags.php
+++ b/module/VuFind/src/VuFind/Db/Table/Tags.php
@@ -111,6 +111,8 @@ class Tags extends Gateway
     {
         $callback = function ($select) use ($text) {
             $select->where->literal('lower(tag) like lower(?)', [$text . '%']);
+            // Discard tags assigned to a user list.
+            $select->where->isNotNull('resource_tags.resource_id');
         };
         return $this->getTagList($sort, $limit, $callback);
     }
@@ -156,6 +158,8 @@ class Tags extends Gateway
             } else {
                 $select->where->equalTo('tags.tag', $q);
             }
+            // Discard tags assigned to a user list.
+            $select->where->isNotNull('rt.resource_id');
 
             if (!empty($source)) {
                 $select->where->equalTo('source', $source);
@@ -327,6 +331,40 @@ class Tags extends Gateway
         return $this->select($callback);
     }
 
+    /**
+     * Get tags assigned to a user list.
+     *
+     * @param int    $listId List ID
+     * @param string $userId User ID to look up (null for no filter).
+     *
+     * @return \Laminas\Db\ResultSet\AbstractResultSet
+     */
+    public function getForList($listId, $userId = null)
+    {
+        $callback = function ($select) use ($listId, $userId) {
+            $select->columns(
+                [
+                    'id' => new Expression(
+                        'min(?)', ['tags.id'],
+                        [Expression::TYPE_IDENTIFIER]
+                    ),
+                    'tag' => $this->caseSensitive
+                        ? 'tag' : new Expression('lower(tag)')
+                ]
+            );
+            $select->join(
+                ['rt' => 'resource_tags'], 'tags.id = rt.tag_id', []
+            );
+            $select->where->equalTo('rt.list_id', $listId);
+            $select->where->isNull('rt.resource_id');
+            if ($userId) {
+                $select->where->equalTo('rt.user_id', $userId);
+            }
+            $select->group(['tag'])->order([new Expression('lower(tag)')]);
+        };
+        return $this->select($callback);
+    }
+
     /**
      * Get a subquery used for flagging tag ownership (see getForResource).
      *
diff --git a/module/VuFind/src/VuFind/Db/Table/UserResource.php b/module/VuFind/src/VuFind/Db/Table/UserResource.php
index 383dc1f44ab..52a242783cd 100644
--- a/module/VuFind/src/VuFind/Db/Table/UserResource.php
+++ b/module/VuFind/src/VuFind/Db/Table/UserResource.php
@@ -156,7 +156,7 @@ class UserResource extends Gateway
         // want to leave orphaned tags in the resource_tags table after we have
         // cleared out favorites in user_resource!
         $resourceTags = $this->getDbTable('ResourceTags');
-        $resourceTags->destroyLinks($resource_id, $user_id, $list_id);
+        $resourceTags->destroyResourceLinks($resource_id, $user_id, $list_id);
 
         // Now build the where clause to figure out which rows to remove:
         $callback = function ($select) use ($resource_id, $user_id, $list_id) {
@@ -168,7 +168,7 @@ class UserResource extends Gateway
                 $select->where->in('resource_id', $resource_id);
             }
             // null or true values of $list_id have different meanings in the
-            // context of the $resourceTags->destroyLinks() call above, since
+            // context of the $resourceTags->destroyResourceLinks() call above, since
             // some tags have a null $list_id value. In the case of user_resource
             // rows, however, every row has a non-null $list_id value, so the
             // two cases are equivalent and may be handled identically.
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/UserTags.php b/module/VuFind/src/VuFind/View/Helper/Root/UserTags.php
index 3b86eff300a..7c6ec5a063c 100644
--- a/module/VuFind/src/VuFind/View/Helper/Root/UserTags.php
+++ b/module/VuFind/src/VuFind/View/Helper/Root/UserTags.php
@@ -47,14 +47,23 @@ class UserTags extends AbstractHelper
      */
     protected $mode;
 
+    /**
+     * List tag mode (enabled or disabled)
+     *
+     * @var string
+     */
+    protected $listMode;
+
     /**
      * Constructor
      *
-     * @param string $mode Tag mode (enabled or disabled)
+     * @param string $mode     Tag mode (enabled or disabled)
+     * @param string $listMode List tag mode (enabled or disabled)
      */
-    public function __construct($mode = 'enabled')
+    public function __construct($mode = 'enabled', $listMode = 'disabled')
     {
         $this->mode = $mode;
+        $this->listMode = $listMode;
     }
 
     /**
@@ -66,4 +75,14 @@ class UserTags extends AbstractHelper
     {
         return $this->mode;
     }
+
+    /**
+     * Get list mode
+     *
+     * @return string
+     */
+    public function getListMode()
+    {
+        return $this->listMode;
+    }
 }
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/UserTagsFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/UserTagsFactory.php
index f1d9459cec9..835fd939a30 100644
--- a/module/VuFind/src/VuFind/View/Helper/Root/UserTagsFactory.php
+++ b/module/VuFind/src/VuFind/View/Helper/Root/UserTagsFactory.php
@@ -62,6 +62,9 @@ class UserTagsFactory implements FactoryInterface
             throw new \Exception('Unexpected options sent to factory.');
         }
         $capabilities = $container->get(\VuFind\Config\AccountCapabilities::class);
-        return new $requestedName($capabilities->getTagSetting());
+        return new $requestedName(
+            $capabilities->getTagSetting(),
+            $capabilities->getListTagSetting()
+        );
     }
 }
diff --git a/module/VuFind/src/VuFindTest/Unit/DbTestCase.php b/module/VuFind/src/VuFindTest/Unit/DbTestCase.php
index 52d6b834950..999461d7f8e 100644
--- a/module/VuFind/src/VuFindTest/Unit/DbTestCase.php
+++ b/module/VuFind/src/VuFindTest/Unit/DbTestCase.php
@@ -154,6 +154,7 @@ abstract class DbTestCase extends TestCase
     public function getTable($table)
     {
         $sm = $this->getServiceManager();
+        $sm->setService(\VuFind\Tags::class, new \VuFind\Tags());
         return $sm->get(\VuFind\Db\Table\PluginManager::class)->get($table);
     }
 }
diff --git a/themes/bootstrap3/templates/myresearch/editlist.phtml b/themes/bootstrap3/templates/myresearch/editlist.phtml
index 1bd67d0d147..971c6128f56 100644
--- a/themes/bootstrap3/templates/myresearch/editlist.phtml
+++ b/themes/bootstrap3/templates/myresearch/editlist.phtml
@@ -22,6 +22,13 @@
     <label class="control-label" for="list_desc"><?=$this->transEsc('Description') ?></label>
     <textarea id="list_desc" class="form-control" name="desc" rows="3"><?=isset($this->list['description']) ? $this->escapeHtml($this->list['description']) : ''?></textarea>
   </div>
+  <?php if ($this->usertags()->getListMode() === 'enabled'): ?>
+    <div class="form-group">
+      <label class="control-label" for="list_tags"><?=$this->transEsc('Tags') ?>:</label>
+      <input type="text" name="tags" id="list_tags" class="form-control" value="<?=$this->escapeHtmlAttr($this->listTags)?>"/>
+      <span class="help-block"><?=$this->transEsc("add_tag_note") ?></span>
+    </div>
+  <?php endif; ?>
   <?php if ($this->userlist()->getMode() === 'public_only'): ?>
     <input type="hidden" name="public" value="1" />
   <?php elseif ($this->userlist()->getMode() === 'private_only'): ?>
diff --git a/themes/bootstrap3/templates/myresearch/mylist.phtml b/themes/bootstrap3/templates/myresearch/mylist.phtml
index c231b4e302b..e461674c88b 100644
--- a/themes/bootstrap3/templates/myresearch/mylist.phtml
+++ b/themes/bootstrap3/templates/myresearch/mylist.phtml
@@ -71,6 +71,9 @@
   <?php if ($list && !empty($list->description)): ?>
     <p><?=$this->escapeHtml($list->description)?></p>
   <?php endif; ?>
+  <?php if (!empty($listTags)): ?>
+    <div><?=$this->transEsc('Tags')?>: <?=implode(', ', array_map([$this, 'escapeHtml'], $listTags))?></div>
+  <?php endif; ?>
   <?php if ($recordTotal > 0): ?>
     <form class="form-inline" method="post" name="bulkActionForm" action="<?=$this->url('cart-myresearchbulk')?>" data-lightbox data-lightbox-onsubmit="bulkFormHandler">
       <?=$this->context($this)->renderInContext('myresearch/bulk-action-buttons.phtml', ['idPrefix' => '', 'list' => $list ?? null, 'account' => $this->account])?>
-- 
GitLab