Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.75% covered (danger)
8.75%
7 / 80
12.00% covered (danger)
12.00%
3 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
TagsService
8.75% covered (danger)
8.75%
7 / 80
12.00% covered (danger)
12.00%
3 / 25
1077.16
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
 parse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 linkTagsToRecord
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getOrCreateTagByText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 linkTagToResource
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 unlinkTagFromResource
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 unlinkTagsFromRecord
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 fixDuplicateTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasCaseSensitiveTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatistics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNonListTagsFuzzilyMatchingString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTagsByText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTagByText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResourcesMatchingTagQuery
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTagBrowseList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRecordTags
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRecordTagsFromFavorites
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getRecordTagsNotInFavorites
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getDuplicateTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserTagsFromFavorites
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getListTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUniqueTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResourceTagsPaginator
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getUserListsByTagAndId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 deleteOrphanedTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Service for handling tag processing.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010-2024.
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  Tags
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/wiki/ Wiki
28 */
29
30namespace VuFind\Tags;
31
32use Laminas\Paginator\Paginator;
33use VuFind\Db\Entity\ResourceEntityInterface;
34use VuFind\Db\Entity\TagsEntityInterface;
35use VuFind\Db\Entity\UserEntityInterface;
36use VuFind\Db\Entity\UserListEntityInterface;
37use VuFind\Db\Service\Feature\TransactionInterface;
38use VuFind\Db\Service\ResourceTagsServiceInterface;
39use VuFind\Db\Service\TagServiceInterface;
40use VuFind\Db\Service\UserListServiceInterface;
41use VuFind\Db\Table\DbTableAwareInterface;
42use VuFind\Db\Table\DbTableAwareTrait;
43use VuFind\Record\ResourcePopulator;
44use VuFind\RecordDriver\AbstractBase as RecordDriver;
45
46use function is_array;
47
48/**
49 * Service for handling tag processing.
50 *
51 * @category VuFind
52 * @package  Tags
53 * @author   Demian Katz <demian.katz@villanova.edu>
54 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
55 * @link     https://vufind.org/wiki/ Wiki
56 */
57class TagsService implements DbTableAwareInterface
58{
59    use DbTableAwareTrait;
60
61    /**
62     * Constructor
63     *
64     * @param TagServiceInterface                               $tagDbService        Tag database service
65     * @param ResourceTagsServiceInterface&TransactionInterface $resourceTagsService Resource/Tags database service
66     * @param UserListServiceInterface                          $userListService     User list database service
67     * @param ResourcePopulator                                 $resourcePopulator   Resource populator service
68     * @param int                                               $maxLength           Maximum tag length
69     * @param bool                                              $caseSensitive       Are tags case sensitive?
70     */
71    public function __construct(
72        protected TagServiceInterface $tagDbService,
73        protected ResourceTagsServiceInterface&TransactionInterface $resourceTagsService,
74        protected UserListServiceInterface $userListService,
75        protected ResourcePopulator $resourcePopulator,
76        protected int $maxLength = 64,
77        protected bool $caseSensitive = false
78    ) {
79    }
80
81    /**
82     * Parse a user-submitted tag string into an array of separate tags.
83     *
84     * @param string $tags User-provided tags
85     *
86     * @return array
87     */
88    public function parse($tags)
89    {
90        preg_match_all('/"[^"]*"|[^ ]+/', trim($tags), $words);
91        $result = [];
92        foreach ($words[0] as $tag) {
93            // Wipe out double-quotes and trim over-long tags:
94            $result[] = substr(str_replace('"', '', $tag), 0, $this->maxLength);
95        }
96        return array_unique($result);
97    }
98
99    /**
100     * Add tags to the record.
101     *
102     * @param RecordDriver        $driver Driver representing record being tagged
103     * @param UserEntityInterface $user   The user adding the tag(s)
104     * @param string|string[]     $tags   The user-provided tag(s), either as a string (to parse) or an
105     * array (already parsed)
106     *
107     * @return void
108     */
109    public function linkTagsToRecord(RecordDriver $driver, UserEntityInterface $user, string|array $tags): void
110    {
111        $parsedTags = is_array($tags) ? $tags : $this->parse($tags);
112        $resource = $this->resourcePopulator->getOrCreateResourceForDriver($driver);
113        foreach ($parsedTags as $tag) {
114            $this->linkTagToResource($tag, $resource, $user);
115        }
116    }
117
118    /**
119     * Get a tag entity if it exists; create it otherwise.
120     *
121     * @param string $tag Text of tag to fetch/create
122     *
123     * @return TagsEntityInterface
124     */
125    public function getOrCreateTagByText(string $tag): TagsEntityInterface
126    {
127        if ($entity = $this->getTagByText($tag)) {
128            return $entity;
129        }
130        $newEntity = $this->tagDbService->createEntity()
131            ->setTag($this->caseSensitive ? $tag : mb_strtolower($tag, 'UTF8'));
132        $this->tagDbService->persistEntity($newEntity);
133        return $newEntity;
134    }
135
136    /**
137     * Unlink a tag from a resource object.
138     *
139     * @param string                           $tagText      Text of tag to link (empty strings will be ignored)
140     * @param ResourceEntityInterface|int      $resourceOrId Resource entity or ID to link
141     * @param UserEntityInterface|int          $userOrId     Owner of tag link
142     * @param null|UserListEntityInterface|int $listOrId     Optional list (omit to tag at resource level)
143     *
144     * @return void
145     */
146    public function linkTagToResource(
147        string $tagText,
148        ResourceEntityInterface|int $resourceOrId,
149        UserEntityInterface|int $userOrId,
150        UserListEntityInterface|int|null $listOrId = null
151    ): void {
152        if (($trimmedTagText = trim($tagText)) !== '') {
153            $this->resourceTagsService->beginTransaction();
154            $this->resourceTagsService->createLink(
155                $resourceOrId,
156                $this->getOrCreateTagByText($trimmedTagText),
157                $userOrId,
158                $listOrId
159            );
160            $this->resourceTagsService->commitTransaction();
161        }
162    }
163
164    /**
165     * Unlink a tag from a resource object.
166     *
167     * @param string                           $tagText      Text of tag to unlink
168     * @param ResourceEntityInterface|int      $resourceOrId Resource entity or ID to unlink
169     * @param UserEntityInterface|int          $userOrId     Owner of tag to unlink
170     * @param null|UserListEntityInterface|int $listOrId     Optional filter (only unlink from this list if provided)
171     *
172     * @return void
173     */
174    public function unlinkTagFromResource(
175        string $tagText,
176        ResourceEntityInterface|int $resourceOrId,
177        UserEntityInterface|int $userOrId,
178        UserListEntityInterface|int|null $listOrId = null
179    ) {
180        $listId = $listOrId instanceof UserListEntityInterface ? $listOrId->getId() : $listOrId;
181        if (($trimmedTagText = trim($tagText)) !== '') {
182            $tagIds = [];
183            foreach ($this->getTagsByText($trimmedTagText) as $tag) {
184                $tagIds[] = $tag->getId();
185            }
186            if ($tagIds) {
187                $this->resourceTagsService->destroyResourceTagsLinksForUser(
188                    $resourceOrId instanceof ResourceEntityInterface ? $resourceOrId->getId() : $resourceOrId,
189                    $userOrId,
190                    $listId,
191                    $tagIds
192                );
193            }
194        }
195    }
196
197    /**
198     * Remove tags from the record.
199     *
200     * @param RecordDriver        $driver Driver representing record being tagged
201     * @param UserEntityInterface $user   The user deleting the tag(s)
202     * @param string[]            $tags   The user-provided tag(s)
203     *
204     * @return void
205     */
206    public function unlinkTagsFromRecord(RecordDriver $driver, UserEntityInterface $user, array $tags): void
207    {
208        $resource = $this->resourcePopulator->getOrCreateResourceForDriver($driver);
209        foreach ($tags as $tag) {
210            $this->unlinkTagFromResource($tag, $resource, $user);
211        }
212    }
213
214    /**
215     * Repair duplicate tags in the database (if any).
216     *
217     * @return void
218     */
219    public function fixDuplicateTags(): void
220    {
221        $this->getDbTable('Tags')->fixDuplicateTags($this->caseSensitive);
222    }
223
224    /**
225     * Are tags case-sensitive?
226     *
227     * @return bool
228     */
229    public function hasCaseSensitiveTags(): bool
230    {
231        return $this->caseSensitive;
232    }
233
234    /**
235     * Get statistics on use of tags.
236     *
237     * @param bool $extended Include extended (unique/anonymous) stats.
238     *
239     * @return array
240     */
241    public function getStatistics(bool $extended = false): array
242    {
243        return $this->tagDbService->getStatistics($extended, $this->caseSensitive);
244    }
245
246    /**
247     * Get the tags that match a string
248     *
249     * @param string $text  Tag to look up.
250     * @param string $sort  Sort type
251     * @param int    $limit Maximum results to retrieve
252     *
253     * @return array
254     */
255    public function getNonListTagsFuzzilyMatchingString(
256        string $text,
257        string $sort = 'alphabetical',
258        int $limit = 100
259    ): array {
260        return $this->tagDbService->getNonListTagsFuzzilyMatchingString($text, $sort, $limit, $this->caseSensitive);
261    }
262
263    /**
264     * Get all matching tags by text. Normally, 0 or 1 results will be retrieved, but more
265     * may be retrieved under exceptional circumstances (e.g. if retrieving case-insensitively
266     * after storing data case-sensitively).
267     *
268     * @param string $text Tag text to match
269     *
270     * @return TagsEntityInterface[]
271     */
272    public function getTagsByText(string $text): array
273    {
274        return $this->tagDbService->getTagsByText($text, $this->caseSensitive);
275    }
276
277    /**
278     * Get the first available matching tag by text; return null if no match is found.
279     *
280     * @param string $text Tag text to match
281     *
282     * @return TagsEntityInterface[]
283     */
284    public function getTagByText(string $text): ?TagsEntityInterface
285    {
286        return $this->tagDbService->getTagByText($text, $this->caseSensitive);
287    }
288
289    /**
290     * Get all resources associated with the provided tag query.
291     *
292     * @param string $q      Search query
293     * @param string $source Record source (optional limiter)
294     * @param string $sort   Resource field to sort on (optional)
295     * @param int    $offset Offset for results
296     * @param ?int   $limit  Limit for results (null for none)
297     * @param bool   $fuzzy  Are we doing an exact (false) or fuzzy (true) search?
298     *
299     * @return array
300     */
301    public function getResourcesMatchingTagQuery(
302        string $q,
303        string $source = null,
304        string $sort = null,
305        int $offset = 0,
306        ?int $limit = null,
307        bool $fuzzy = true
308    ): array {
309        return $this->tagDbService
310            ->getResourcesMatchingTagQuery($q, $source, $sort, $offset, $limit, $fuzzy, $this->caseSensitive);
311    }
312
313    /**
314     * Get a list of tags for the browse interface.
315     *
316     * @param string $sort  Sort/search parameter
317     * @param int    $limit Maximum number of tags (default = 100, < 1 = no limit)
318     *
319     * @return array
320     */
321    public function getTagBrowseList(string $sort, int $limit): array
322    {
323        return $this->tagDbService->getTagBrowseList($sort, $limit, $this->caseSensitive);
324    }
325
326    /**
327     * Get all tags associated with the specified record (and matching provided filters).
328     *
329     * @param string                           $id        Record ID to look up
330     * @param string                           $source    Source of record to look up
331     * @param int                              $limit     Max. number of tags to return (0 = no limit)
332     * @param UserListEntityInterface|int|null $listOrId  ID of list to load tags from (null for no restriction)
333     * @param UserEntityInterface|int|null     $userOrId  ID of user to load tags from (null for all users)
334     * @param string                           $sort      Sort type ('count' or 'tag')
335     * @param UserEntityInterface|int|null     $ownerOrId ID of user to check for ownership
336     *
337     * @return array
338     */
339    public function getRecordTags(
340        string $id,
341        string $source = DEFAULT_SEARCH_BACKEND,
342        int $limit = 0,
343        UserListEntityInterface|int|null $listOrId = null,
344        UserEntityInterface|int|null $userOrId = null,
345        string $sort = 'count',
346        UserEntityInterface|int|null $ownerOrId = null
347    ): array {
348        return $this->tagDbService
349            ->getRecordTags($id, $source, $limit, $listOrId, $userOrId, $sort, $ownerOrId, $this->caseSensitive);
350    }
351
352    /**
353     * Get all tags from favorite lists associated with the specified record (and matching provided filters).
354     *
355     * @param string                           $id        Record ID to look up
356     * @param string                           $source    Source of record to look up
357     * @param int                              $limit     Max. number of tags to return (0 = no limit)
358     * @param UserListEntityInterface|int|null $listOrId  ID of list to load tags from (null for tags that
359     *                                                    are associated with ANY list, but excluding
360     *                                                    non-list tags)
361     * @param UserEntityInterface|int|null     $userOrId  ID of user to load tags from (null for all users)
362     * @param string                           $sort      Sort type ('count' or 'tag')
363     * @param UserEntityInterface|int|null     $ownerOrId ID of user to check for ownership
364     * (this will not filter the result list, but rows owned by this user will have an is_me column set to 1)
365     *
366     * @return array
367     */
368    public function getRecordTagsFromFavorites(
369        string $id,
370        string $source = DEFAULT_SEARCH_BACKEND,
371        int $limit = 0,
372        UserListEntityInterface|int|null $listOrId = null,
373        UserEntityInterface|int|null $userOrId = null,
374        string $sort = 'count',
375        UserEntityInterface|int|null $ownerOrId = null
376    ) {
377        return $this->tagDbService->getRecordTagsFromFavorites(
378            $id,
379            $source,
380            $limit,
381            $listOrId,
382            $userOrId,
383            $sort,
384            $ownerOrId,
385            $this->caseSensitive
386        );
387    }
388
389    /**
390     * Get all tags outside of favorite lists associated with the specified record (and matching provided filters).
391     *
392     * @param string                       $id        Record ID to look up
393     * @param string                       $source    Source of record to look up
394     * @param int                          $limit     Max. number of tags to return (0 = no limit)
395     * @param UserEntityInterface|int|null $userOrId  User entity/ID to load tags from (null for all users)
396     * @param string                       $sort      Sort type ('count' or 'tag')
397     * @param UserEntityInterface|int|null $ownerOrId ID of user to check for ownership
398     * (this will not filter the result list, but rows owned by this user will have an is_me column set to 1)
399     *
400     * @return array
401     */
402    public function getRecordTagsNotInFavorites(
403        string $id,
404        string $source = DEFAULT_SEARCH_BACKEND,
405        int $limit = 0,
406        UserEntityInterface|int|null $userOrId = null,
407        string $sort = 'count',
408        UserEntityInterface|int|null $ownerOrId = null
409    ): array {
410        return $this->tagDbService->getRecordTagsNotInFavorites(
411            $id,
412            $source,
413            $limit,
414            $userOrId,
415            $sort,
416            $ownerOrId,
417            $this->caseSensitive
418        );
419    }
420
421    /**
422     * Get a list of duplicate tags (this should never happen, but past bugs and the introduction of case-insensitive
423     * tags have introduced problems).
424     *
425     * @return array
426     */
427    public function getDuplicateTags(): array
428    {
429        return $this->tagDbService->getDuplicateTags($this->caseSensitive);
430    }
431
432    /**
433     * Get a list of all tags generated by the user in favorites lists. Note that the returned list WILL NOT include
434     * tags attached to records that are not saved in favorites lists. Returns an array of arrays with id and tag keys.
435     *
436     * @param UserEntityInterface|int          $userOrId User ID to look up.
437     * @param UserListEntityInterface|int|null $listOrId Filter for tags tied to a specific list (null for no filter).
438     * @param ?string                          $recordId Filter for tags tied to a specific resource (null for no
439     * filter).
440     * @param ?string                          $source   Filter for tags tied to a specific record source (null
441     * for no filter).
442     *
443     * @return array
444     */
445    public function getUserTagsFromFavorites(
446        UserEntityInterface|int $userOrId,
447        UserListEntityInterface|int|null $listOrId = null,
448        ?string $recordId = null,
449        ?string $source = null
450    ): array {
451        return $this->tagDbService
452            ->getUserTagsFromFavorites($userOrId, $listOrId, $recordId, $source, $this->caseSensitive);
453    }
454
455    /**
456     * Get tags assigned to a user list. Returns an array of arrays with id and tag keys.
457     *
458     * @param UserListEntityInterface|int  $listOrId List ID or entity
459     * @param UserEntityInterface|int|null $userOrId User ID or entity to look up (null for no filter).
460     *
461     * @return array[]
462     */
463    public function getListTags(
464        UserListEntityInterface|int $listOrId,
465        UserEntityInterface|int|null $userOrId = null,
466    ): array {
467        return $this->tagDbService->getListTags($listOrId, $userOrId, $this->caseSensitive);
468    }
469
470    /**
471     * Gets unique tags from the database.
472     *
473     * @param ?int $userId     ID of user (null for any)
474     * @param ?int $resourceId ID of the resource (null for any)
475     * @param ?int $tagId      ID of the tag (null for any)
476     *
477     * @return array[]
478     */
479    public function getUniqueTags(
480        ?int $userId = null,
481        ?int $resourceId = null,
482        ?int $tagId = null
483    ): array {
484        return $this->resourceTagsService->getUniqueTags($userId, $resourceId, $tagId, $this->caseSensitive);
485    }
486
487    /**
488     * Get Resource Tags Paginator
489     *
490     * @param ?int    $userId     ID of user (null for any)
491     * @param ?int    $resourceId ID of the resource (null for any)
492     * @param ?int    $tagId      ID of the tag (null for any)
493     * @param ?string $order      The order in which to return the data
494     * @param ?int    $page       The page number to select
495     * @param int     $limit      The number of items to fetch
496     *
497     * @return Paginator
498     */
499    public function getResourceTagsPaginator(
500        ?int $userId = null,
501        ?int $resourceId = null,
502        ?int $tagId = null,
503        ?string $order = null,
504        ?int $page = null,
505        int $limit = 20
506    ): Paginator {
507        return $this->resourceTagsService
508            ->getResourceTagsPaginator($userId, $resourceId, $tagId, $order, $page, $limit, $this->caseSensitive);
509    }
510
511    /**
512     * Get lists associated with a particular tag and/or list of IDs. If IDs and
513     * tags are both provided, only the intersection of matches will be returned.
514     *
515     * @param string|string[]|null $tag        Tag or tags to match (by text, not ID; null for all)
516     * @param int|int[]|null       $listId     List ID or IDs to match (null for all)
517     * @param bool                 $publicOnly Whether to return only public lists
518     * @param bool                 $andTags    Use AND operator when filtering by tag.
519     *
520     * @return UserListEntityInterface[]
521     */
522    public function getUserListsByTagAndId(
523        string|array|null $tag = null,
524        int|array|null $listId = null,
525        bool $publicOnly = true,
526        bool $andTags = true
527    ): array {
528        return $this->userListService
529            ->getUserListsByTagAndId($tag, $listId, $publicOnly, $andTags, $this->caseSensitive);
530    }
531
532    /**
533     * Delete orphaned tags (those not present in resource_tags) from the tags table.
534     *
535     * @return void
536     */
537    public function deleteOrphanedTags(): void
538    {
539        $this->tagDbService->deleteOrphanedTags();
540    }
541}