Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.67% covered (danger)
8.67%
13 / 150
9.09% covered (danger)
9.09%
2 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
FavoritesService
8.67% covered (danger)
8.67%
13 / 150
9.09% covered (danger)
9.09%
2 / 22
2711.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createListForUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 destroyList
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 rememberLastUsedList
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getAndRememberListObject
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getListIdFromParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getLastUsedList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 persistToCache
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 removeListResourcesById
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 removeUserResourcesById
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 save
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 saveResourceToFavorites
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 saveRecordToFavorites
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 saveListForUser
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 addListTag
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 updateListFromRequest
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 userCanEditList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 cacheBatch
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 saveRecordsToFavorites
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 deleteFavorites
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 getTagStringForEditing
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 formatTagStringForEditing
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3/**
4 * Favorites service
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2016.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Favorites
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Page
28 */
29
30namespace VuFind\Favorites;
31
32use DateTime;
33use Laminas\Session\Container;
34use Laminas\Stdlib\Parameters;
35use VuFind\Db\Entity\ResourceEntityInterface;
36use VuFind\Db\Entity\UserEntityInterface;
37use VuFind\Db\Entity\UserListEntityInterface;
38use VuFind\Db\Service\Feature\TransactionInterface;
39use VuFind\Db\Service\ResourceServiceInterface;
40use VuFind\Db\Service\ResourceTagsServiceInterface;
41use VuFind\Db\Service\UserListServiceInterface;
42use VuFind\Db\Service\UserResourceServiceInterface;
43use VuFind\Db\Service\UserServiceInterface;
44use VuFind\Exception\ListPermission as ListPermissionException;
45use VuFind\Exception\LoginRequired as LoginRequiredException;
46use VuFind\Exception\MissingField as MissingFieldException;
47use VuFind\I18n\Translator\TranslatorAwareInterface;
48use VuFind\Record\Cache as RecordCache;
49use VuFind\Record\Loader as RecordLoader;
50use VuFind\Record\ResourcePopulator;
51use VuFind\RecordDriver\AbstractBase as RecordDriver;
52use VuFind\Tags\TagsService;
53
54use function count;
55use function func_get_args;
56use function intval;
57
58/**
59 * Favorites service
60 *
61 * @category VuFind
62 * @package  Favorites
63 * @author   Demian Katz <demian.katz@villanova.edu>
64 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
65 * @link     https://vufind.org Main Page
66 */
67class FavoritesService implements TranslatorAwareInterface
68{
69    use \VuFind\I18n\Translator\TranslatorAwareTrait;
70
71    /**
72     * Constructor
73     *
74     * @param ResourceServiceInterface                          $resourceService     Resource database service
75     * @param ResourceTagsServiceInterface&TransactionInterface $resourceTagsService Resource tags database service
76     * @param UserListServiceInterface                          $userListService     UserList database service
77     * @param UserResourceServiceInterface                      $userResourceService UserResource database service
78     * @param UserServiceInterface                              $userService         User database service
79     * @param ResourcePopulator                                 $resourcePopulator   Resource populator service
80     * @param TagsService                                       $tagsService         Tags service
81     * @param RecordLoader                                      $recordLoader        Record loader
82     * @param ?RecordCache                                      $recordCache         Record cache (optional)
83     * @param ?Container                                        $session             Session container for remembering
84     * state (optional)
85     */
86    public function __construct(
87        protected ResourceServiceInterface $resourceService,
88        protected ResourceTagsServiceInterface&TransactionInterface $resourceTagsService,
89        protected UserListServiceInterface $userListService,
90        protected UserResourceServiceInterface $userResourceService,
91        protected UserServiceInterface $userService,
92        protected ResourcePopulator $resourcePopulator,
93        protected TagsService $tagsService,
94        protected RecordLoader $recordLoader,
95        protected ?RecordCache $recordCache = null,
96        protected ?Container $session = null
97    ) {
98    }
99
100    /**
101     * Create a new list object for the specified user.
102     *
103     * @param ?UserEntityInterface $user Logged in user (null if logged out)
104     *
105     * @return UserListEntityInterface
106     * @throws LoginRequiredException
107     */
108    public function createListForUser(?UserEntityInterface $user): UserListEntityInterface
109    {
110        if (!$user) {
111            throw new LoginRequiredException('Log in to create lists.');
112        }
113
114        return $this->userListService->createEntity()
115            ->setCreated(new DateTime())
116            ->setUser($user);
117    }
118
119    /**
120     * Destroy a list.
121     *
122     * @param UserListEntityInterface $list  List to destroy
123     * @param ?UserEntityInterface    $user  Logged-in user (null if none)
124     * @param bool                    $force Should we force the delete without checking permissions?
125     *
126     * @return void
127     * @throws ListPermissionException
128     */
129    public function destroyList(
130        UserListEntityInterface $list,
131        ?UserEntityInterface $user = null,
132        bool $force = false
133    ): void {
134        if (!$force && !$this->userCanEditList($user, $list)) {
135            throw new ListPermissionException('list_access_denied');
136        }
137
138        // Remove user_resource and resource_tags rows for favorites tags:
139        $listUser = $list->getUser();
140        $this->resourceTagsService->destroyResourceTagsLinksForUser(null, $listUser, $list);
141        $this->userResourceService->unlinkFavorites(null, $listUser, $list);
142
143        // Remove resource_tags rows for list tags:
144        $this->resourceTagsService->destroyUserListLinks($list, $user);
145
146        // Clean up orphaned tags:
147        $this->tagsService->deleteOrphanedTags();
148
149        $this->userListService->deleteUserList($list);
150    }
151
152    /**
153     * Remember that this list was used so that it can become the default in
154     * dialog boxes.
155     *
156     * @param UserListEntityInterface $list List to remember
157     *
158     * @return void
159     */
160    public function rememberLastUsedList(UserListEntityInterface $list): void
161    {
162        if (null !== $this->session) {
163            $this->session->lastUsed = $list->getId();
164        }
165    }
166
167    /**
168     * Get a list object for the specified ID (or null to create a new list).
169     * Ensure that the object is persisted to the database if it does not
170     * already exist, and remember it as the user's last-accessed list.
171     *
172     * @param ?int                $listId List ID (or null to create a new list)
173     * @param UserEntityInterface $user   The user saving the record
174     *
175     * @return UserListEntityInterface
176     *
177     * @throws \VuFind\Exception\ListPermission
178     */
179    public function getAndRememberListObject(?int $listId, UserEntityInterface $user): UserListEntityInterface
180    {
181        if (empty($listId)) {
182            $list = $this->createListForUser($user)
183                ->setTitle($this->translate('default_list_title'));
184            $this->saveListForUser($list, $user);
185        } else {
186            $list = $this->userListService->getUserListById($listId);
187            // Validate incoming list ID:
188            if (!$this->userCanEditList($user, $list)) {
189                throw new \VuFind\Exception\ListPermission('Access denied.');
190            }
191            $this->rememberLastUsedList($list); // handled by saveListForUser() in other case
192        }
193        return $list;
194    }
195
196    /**
197     * Given an array of parameters, extract a list ID if possible. Return null
198     * if no valid ID is found or if a "NEW" record is requested.
199     *
200     * @param array $params Parameters to process
201     *
202     * @return ?int
203     */
204    public function getListIdFromParams(array $params): ?int
205    {
206        return intval($params['list'] ?? 'NEW') ?: null;
207    }
208
209    /**
210     * Retrieve the ID of the last list that was accessed, if any.
211     *
212     * @return ?int Identifier value of a UserListEntityInterface object (if set) or null (if not available).
213     */
214    public function getLastUsedList(): ?int
215    {
216        return $this->session->lastUsed ?? null;
217    }
218
219    /**
220     * Persist a resource to the record cache (if applicable).
221     *
222     * @param RecordDriver            $driver   Record driver to persist
223     * @param ResourceEntityInterface $resource Resource row
224     *
225     * @return void
226     */
227    protected function persistToCache(
228        RecordDriver $driver,
229        ResourceEntityInterface $resource
230    ) {
231        if ($this->recordCache) {
232            $this->recordCache->setContext(RecordCache::CONTEXT_FAVORITE);
233            $this->recordCache->createOrUpdate(
234                $resource->getRecordId(),
235                $resource->getSource(),
236                $driver->getRawData()
237            );
238        }
239    }
240
241    /**
242     * Given an array of item ids, remove them from the specified list.
243     *
244     * @param UserListEntityInterface $list   List being updated
245     * @param ?UserEntityInterface    $user   Logged-in user (null if none)
246     * @param string[]                $ids    IDs to remove from the list
247     * @param string                  $source Type of resource identified by IDs
248     *
249     * @return void
250     */
251    public function removeListResourcesById(
252        UserListEntityInterface $list,
253        ?UserEntityInterface $user,
254        array $ids,
255        string $source = DEFAULT_SEARCH_BACKEND
256    ): void {
257        if (!$this->userCanEditList($user, $list)) {
258            throw new ListPermissionException('list_access_denied');
259        }
260
261        // Retrieve a list of resource IDs:
262        $resources = $this->resourceService->getResourcesByRecordIds($ids, $source);
263
264        $resourceIDs = [];
265        foreach ($resources as $current) {
266            $resourceIDs[] = $current->getId();
267        }
268
269        // Remove Resource and related tags:
270        $listUser = $list->getUser();
271        $this->resourceTagsService->destroyResourceTagsLinksForUser($resourceIDs, $listUser, $list);
272        $this->userResourceService->unlinkFavorites($resourceIDs, $listUser, $list);
273        $this->tagsService->deleteOrphanedTags();
274    }
275
276    /**
277     * Given an array of item ids, remove them from all of the specified user's lists
278     *
279     * @param UserEntityInterface $user   User owning lists
280     * @param string[]            $ids    IDs to remove from the list
281     * @param string              $source Type of resource identified by IDs
282     *
283     * @return void
284     */
285    public function removeUserResourcesById(
286        UserEntityInterface $user,
287        array $ids,
288        $source = DEFAULT_SEARCH_BACKEND
289    ): void {
290        // Retrieve a list of resource IDs:
291        $resources = $this->resourceService->getResourcesByRecordIds($ids, $source);
292
293        $resourceIDs = [];
294        foreach ($resources as $current) {
295            $resourceIDs[] = $current->getId();
296        }
297
298        // Remove resource and related tags:
299        $this->resourceTagsService->destroyAllListResourceTagsLinksForUser($resourceIDs, $user);
300        $this->userResourceService->unlinkFavorites($resourceIDs, $user->getId(), null);
301        $this->tagsService->deleteOrphanedTags();
302    }
303
304    /**
305     * Legacy name for saveRecordToFavorites()
306     *
307     * @return array
308     *
309     * @deprecated Use saveRecordToFavorites()
310     */
311    public function save()
312    {
313        return $this->saveRecordToFavorites(...func_get_args());
314    }
315
316    /**
317     * Add/update a resource in the user's account.
318     *
319     * @param UserEntityInterface|int     $userOrId        The user entity or ID saving the favorites
320     * @param ResourceEntityInterface|int $resourceOrId    The resource entity or ID to add/update
321     * @param UserListEntityInterface|int $listOrId        The list entity or ID to store the resource in.
322     * @param array                       $tagArray        An array of tags to associate with the resource.
323     * @param string                      $notes           User notes about the resource.
324     * @param bool                        $replaceExisting Whether to replace all existing tags (true) or
325     * append to the existing list (false).
326     *
327     * @return void
328     */
329    public function saveResourceToFavorites(
330        UserEntityInterface|int $userOrId,
331        ResourceEntityInterface|int $resourceOrId,
332        UserListEntityInterface|int $listOrId,
333        array $tagArray,
334        string $notes,
335        bool $replaceExisting = true
336    ): void {
337        $user = $userOrId instanceof UserEntityInterface
338            ? $userOrId
339            : $this->userService->getUserById($userOrId);
340        $resource = $resourceOrId instanceof ResourceEntityInterface
341            ? $resourceOrId
342            : $this->resourceService->getResourceById($resourceOrId);
343        $list = $listOrId instanceof UserListEntityInterface
344            ? $listOrId
345            : $this->userListService->getUserListById($listOrId);
346
347        // Create the resource link if it doesn't exist and update the notes in any
348        // case:
349        $this->userResourceService->createOrUpdateLink($resource, $user, $list, $notes);
350
351        // If we're replacing existing tags, delete the old ones before adding the new ones:
352        if ($replaceExisting) {
353            $this->resourceTagsService->destroyResourceTagsLinksForUser($resource->getId(), $user, $list);
354        }
355
356        // Add the new tags:
357        foreach ($tagArray as $tag) {
358            $this->tagsService->linkTagToResource($tag, $resource, $user, $list);
359        }
360    }
361
362    /**
363     * Save this record to the user's favorites.
364     *
365     * @param array               $params Array with some or all of these keys:
366     *  <ul>
367     *    <li>mytags - Tag array to associate with record (optional)</li>
368     *    <li>notes - Notes to associate with record (optional)</li>
369     *    <li>list - ID of list to save record into (omit to create new list)</li>
370     *  </ul>
371     * @param UserEntityInterface $user   The user saving the record
372     * @param RecordDriver        $driver Record driver for record being saved
373     *
374     * @return array list information
375     */
376    public function saveRecordToFavorites(
377        array $params,
378        UserEntityInterface $user,
379        RecordDriver $driver
380    ): array {
381        // Validate incoming parameters:
382        if (!$user) {
383            throw new LoginRequiredException('You must be logged in first');
384        }
385
386        // Get or create a list object as needed:
387        $list = $this->getAndRememberListObject($this->getListIdFromParams($params), $user);
388
389        // Get or create a resource object as needed:
390        $resource = $this->resourcePopulator->getOrCreateResourceForDriver($driver);
391
392        // Persist record in the database for "offline" use
393        $this->persistToCache($driver, $resource);
394
395        // Add the information to the user's account:
396        $this->saveResourceToFavorites(
397            $user,
398            $resource,
399            $list,
400            $params['mytags'] ?? [],
401            $params['notes'] ?? ''
402        );
403        return ['listId' => $list->getId()];
404    }
405
406    /**
407     * Saves the provided list to the database and remembers it in the session if it is valid;
408     * throws an exception otherwise.
409     *
410     * @param UserListEntityInterface $list List to save
411     * @param ?UserEntityInterface    $user Logged-in user (null if none)
412     *
413     * @return void
414     * @throws ListPermissionException
415     * @throws MissingFieldException
416     */
417    public function saveListForUser(UserListEntityInterface $list, ?UserEntityInterface $user): void
418    {
419        if (!$this->userCanEditList($user, $list)) {
420            throw new ListPermissionException('list_access_denied');
421        }
422        if (!$list->getTitle()) {
423            throw new MissingFieldException('list_edit_name_required');
424        }
425
426        $this->userListService->persistEntity($list);
427        $this->rememberLastUsedList($list);
428    }
429
430    /**
431     * Add a tag to a list.
432     *
433     * @param string                  $tagText The tag to save.
434     * @param UserListEntityInterface $list    The list being tagged.
435     * @param UserEntityInterface     $user    The user posting the tag.
436     *
437     * @return void
438     */
439    public function addListTag(string $tagText, UserListEntityInterface $list, UserEntityInterface $user): void
440    {
441        $tagText = trim($tagText);
442        if (!empty($tagText)) {
443            // Create and link tag in a transaction so we don't accidentally purge it as an orphan:
444            $this->resourceTagsService->beginTransaction();
445            $tag = $this->tagsService->getOrCreateTagByText($tagText);
446            $this->resourceTagsService->createLink(null, $tag, $user, $list);
447            $this->resourceTagsService->commitTransaction();
448        }
449    }
450
451    /**
452     * Update and save the list object using a request object -- useful for
453     * sharing form processing between multiple actions.
454     *
455     * @param UserListEntityInterface $list    List to update
456     * @param ?UserEntityInterface    $user    Logged-in user (false if none)
457     * @param Parameters              $request Request to process
458     *
459     * @return int ID of newly created row
460     * @throws ListPermissionException
461     * @throws MissingFieldException
462     */
463    public function updateListFromRequest(
464        UserListEntityInterface $list,
465        ?UserEntityInterface $user,
466        Parameters $request
467    ): int {
468        $list->setTitle($request->get('title'))
469            ->setDescription($request->get('desc'))
470            ->setPublic((bool)$request->get('public'));
471        $this->saveListForUser($list, $user);
472
473        if (null !== ($tags = $request->get('tags'))) {
474            $this->resourceTagsService->destroyUserListLinks($list, $user);
475            foreach ($this->tagsService->parse($tags) as $tag) {
476                $this->addListTag($tag, $list, $user);
477            }
478        }
479
480        return $list->getId();
481    }
482
483    /**
484     * Is the provided user allowed to edit the provided list?
485     *
486     * @param ?UserEntityInterface    $user Logged-in user (null if none)
487     * @param UserListEntityInterface $list List to check
488     *
489     * @return bool
490     */
491    public function userCanEditList(?UserEntityInterface $user, UserListEntityInterface $list): bool
492    {
493        return $user && $user->getId() === $list->getUser()?->getId();
494    }
495
496    /**
497     * Support method for saveBulk() -- save a batch of records to the cache.
498     *
499     * @param array $cacheRecordIds Array of IDs in source|id format
500     *
501     * @return void
502     */
503    protected function cacheBatch(array $cacheRecordIds)
504    {
505        if ($cacheRecordIds && $this->recordCache) {
506            // Disable the cache so that we fetch latest versions, not cached ones:
507            $this->recordLoader->setCacheContext(RecordCache::CONTEXT_DISABLED);
508            $records = $this->recordLoader->loadBatch($cacheRecordIds);
509            // Re-enable the cache so that we actually save the records:
510            $this->recordLoader->setCacheContext(RecordCache::CONTEXT_FAVORITE);
511            foreach ($records as $record) {
512                $this->recordCache->createOrUpdate(
513                    $record->getUniqueID(),
514                    $record->getSourceIdentifier(),
515                    $record->getRawData()
516                );
517            }
518        }
519    }
520
521    /**
522     * Save a group of records to the user's favorites.
523     *
524     * @param array               $params Array with some or all of these keys:
525     *                                    <ul> <li>ids - Array of IDs in
526     *                                    source|id format</li> <li>mytags -
527     *                                    Unparsed tag string to associate with
528     *                                    record (optional)</li> <li>list - ID
529     *                                    of list to save record into (omit to
530     *                                    create new list)</li> </ul>
531     * @param UserEntityInterface $user   The user saving the record
532     *
533     * @return array list information
534     */
535    public function saveRecordsToFavorites(array $params, UserEntityInterface $user): array
536    {
537        // Load helper objects needed for the saving process:
538        $list = $this->getAndRememberListObject($this->getListIdFromParams($params), $user);
539        $this->recordCache?->setContext(RecordCache::CONTEXT_FAVORITE);
540
541        $cacheRecordIds = [];   // list of record IDs to save to cache
542        foreach ($params['ids'] as $current) {
543            // Break apart components of ID:
544            [$source, $id] = explode('|', $current, 2);
545
546            // Get or create a resource object as needed:
547            $resource = $this->resourcePopulator->getOrCreateResourceForRecordId($id, $source);
548
549            // Add the information to the user's account:
550            $tags = isset($params['mytags']) ? $this->tagsService->parse($params['mytags']) : [];
551            $this->saveResourceToFavorites($user, $resource, $list, $tags, '', false);
552
553            // Collect record IDs for caching
554            if ($this->recordCache?->isCachable($resource->getSource())) {
555                $cacheRecordIds[] = $current;
556            }
557        }
558
559        $this->cacheBatch($cacheRecordIds);
560        return ['listId' => $list->getId()];
561    }
562
563    /**
564     * Delete a group of favorites.
565     *
566     * @param string[]            $ids    Array of IDs in source|id format.
567     * @param ?int                $listID ID of list to delete from (null for all lists)
568     * @param UserEntityInterface $user   Logged in user
569     *
570     * @return void
571     */
572    public function deleteFavorites(array $ids, ?int $listID, UserEntityInterface $user): void
573    {
574        // Sort $ids into useful array:
575        $sorted = [];
576        foreach ($ids as $current) {
577            [$source, $id] = explode('|', $current, 2);
578            if (!isset($sorted[$source])) {
579                $sorted[$source] = [];
580            }
581            $sorted[$source][] = $id;
582        }
583
584        // Delete favorites one source at a time, using a different object depending
585        // on whether we are working with a list or user favorites.
586        if (empty($listID)) {
587            foreach ($sorted as $source => $ids) {
588                $this->removeUserResourcesById($user, $ids, $source);
589            }
590        } else {
591            $list = $this->userListService->getUserListById($listID);
592            foreach ($sorted as $source => $ids) {
593                $this->removeListResourcesById($list, $user, $ids, $source);
594            }
595        }
596    }
597
598    /**
599     * Call TagsService::getUserTagsFromFavorites() and format the results for editing.
600     *
601     * @param UserEntityInterface|int          $userOrId User ID to look up.
602     * @param UserListEntityInterface|int|null $listOrId Filter for tags tied to a specific list (null for no
603     * filter).
604     * @param ?string                          $recordId Filter for tags tied to a specific resource (null for no
605     * filter).
606     * @param ?string                          $source   Filter for tags tied to a specific record source (null for
607     * no filter).
608     *
609     * @return string
610     */
611    public function getTagStringForEditing(
612        UserEntityInterface|int $userOrId,
613        UserListEntityInterface|int|null $listOrId = null,
614        ?string $recordId = null,
615        ?string $source = null
616    ): string {
617        return $this->formatTagStringForEditing(
618            $this->tagsService->getUserTagsFromFavorites(
619                $userOrId,
620                $listOrId,
621                $recordId,
622                $source
623            )
624        );
625    }
626
627    /**
628     * Convert an array representing tags into a string for an edit form
629     *
630     * @param array $tags Tags
631     *
632     * @return string
633     */
634    public function formatTagStringForEditing($tags): string
635    {
636        $tagStr = '';
637        if (count($tags) > 0) {
638            foreach ($tags as $tag) {
639                if (strstr($tag['tag'], ' ')) {
640                    $tagStr .= "\"{$tag['tag']}\" ";
641                } else {
642                    $tagStr .= "{$tag['tag']} ";
643                }
644            }
645        }
646        return trim($tagStr);
647    }
648}