aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/SystemTag
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/SystemTag')
-rw-r--r--lib/private/SystemTag/ManagerFactory.php64
-rw-r--r--lib/private/SystemTag/SystemTag.php59
-rw-r--r--lib/private/SystemTag/SystemTagManager.php449
-rw-r--r--lib/private/SystemTag/SystemTagObjectMapper.php367
-rw-r--r--lib/private/SystemTag/SystemTagsInFilesDetector.php55
5 files changed, 994 insertions, 0 deletions
diff --git a/lib/private/SystemTag/ManagerFactory.php b/lib/private/SystemTag/ManagerFactory.php
new file mode 100644
index 00000000000..4d3a1396529
--- /dev/null
+++ b/lib/private/SystemTag/ManagerFactory.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\SystemTag;
+
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IServerContainer;
+use OCP\IUserSession;
+use OCP\SystemTag\ISystemTagManager;
+use OCP\SystemTag\ISystemTagManagerFactory;
+use OCP\SystemTag\ISystemTagObjectMapper;
+
+/**
+ * Default factory class for system tag managers
+ *
+ * @package OCP\SystemTag
+ * @since 9.0.0
+ */
+class ManagerFactory implements ISystemTagManagerFactory {
+ /**
+ * Constructor for the system tag manager factory
+ */
+ public function __construct(
+ private IServerContainer $serverContainer,
+ ) {
+ }
+
+ /**
+ * Creates and returns an instance of the system tag manager
+ *
+ * @since 9.0.0
+ */
+ public function getManager(): ISystemTagManager {
+ return new SystemTagManager(
+ $this->serverContainer->get(IDBConnection::class),
+ $this->serverContainer->get(IGroupManager::class),
+ $this->serverContainer->get(IEventDispatcher::class),
+ $this->serverContainer->get(IUserSession::class),
+ $this->serverContainer->get(IAppConfig::class),
+ );
+ }
+
+ /**
+ * Creates and returns an instance of the system tag object
+ * mapper
+ *
+ * @since 9.0.0
+ */
+ public function getObjectMapper(): ISystemTagObjectMapper {
+ return new SystemTagObjectMapper(
+ $this->serverContainer->get(IDBConnection::class),
+ $this->getManager(),
+ $this->serverContainer->get(IEventDispatcher::class),
+ );
+ }
+}
diff --git a/lib/private/SystemTag/SystemTag.php b/lib/private/SystemTag/SystemTag.php
new file mode 100644
index 00000000000..1a573dabeaa
--- /dev/null
+++ b/lib/private/SystemTag/SystemTag.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\SystemTag;
+
+use OCP\SystemTag\ISystemTag;
+
+class SystemTag implements ISystemTag {
+ public function __construct(
+ private string $id,
+ private string $name,
+ private bool $userVisible,
+ private bool $userAssignable,
+ private ?string $etag = null,
+ private ?string $color = null,
+ ) {
+ }
+
+ public function getId(): string {
+ return $this->id;
+ }
+
+ public function getName(): string {
+ return $this->name;
+ }
+
+ public function isUserVisible(): bool {
+ return $this->userVisible;
+ }
+
+ public function isUserAssignable(): bool {
+ return $this->userAssignable;
+ }
+
+ public function getAccessLevel(): int {
+ if (!$this->userVisible) {
+ return self::ACCESS_LEVEL_INVISIBLE;
+ }
+
+ if (!$this->userAssignable) {
+ return self::ACCESS_LEVEL_RESTRICTED;
+ }
+
+ return self::ACCESS_LEVEL_PUBLIC;
+ }
+
+ public function getETag(): ?string {
+ return $this->etag;
+ }
+
+ public function getColor(): ?string {
+ return $this->color;
+ }
+}
diff --git a/lib/private/SystemTag/SystemTagManager.php b/lib/private/SystemTag/SystemTagManager.php
new file mode 100644
index 00000000000..4b421fa033a
--- /dev/null
+++ b/lib/private/SystemTag/SystemTagManager.php
@@ -0,0 +1,449 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\SystemTag;
+
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\SystemTag\ISystemTag;
+use OCP\SystemTag\ISystemTagManager;
+use OCP\SystemTag\ManagerEvent;
+use OCP\SystemTag\TagAlreadyExistsException;
+use OCP\SystemTag\TagCreationForbiddenException;
+use OCP\SystemTag\TagNotFoundException;
+use OCP\SystemTag\TagUpdateForbiddenException;
+
+/**
+ * Manager class for system tags
+ */
+class SystemTagManager implements ISystemTagManager {
+ public const TAG_TABLE = 'systemtag';
+ public const TAG_GROUP_TABLE = 'systemtag_group';
+
+ /**
+ * Prepared query for selecting tags directly
+ */
+ private IQueryBuilder $selectTagQuery;
+
+ public function __construct(
+ protected IDBConnection $connection,
+ protected IGroupManager $groupManager,
+ protected IEventDispatcher $dispatcher,
+ private IUserSession $userSession,
+ private IAppConfig $appConfig,
+ ) {
+ $query = $this->connection->getQueryBuilder();
+ $this->selectTagQuery = $query->select('*')
+ ->from(self::TAG_TABLE)
+ ->where($query->expr()->eq('name', $query->createParameter('name')))
+ ->andWhere($query->expr()->eq('visibility', $query->createParameter('visibility')))
+ ->andWhere($query->expr()->eq('editable', $query->createParameter('editable')));
+ }
+
+ public function getTagsByIds($tagIds, ?IUser $user = null): array {
+ if (!\is_array($tagIds)) {
+ $tagIds = [$tagIds];
+ }
+
+ $tags = [];
+
+ // note: not all databases will fail if it's a string or starts with a number
+ foreach ($tagIds as $tagId) {
+ if (!is_numeric($tagId)) {
+ throw new \InvalidArgumentException('Tag id must be integer');
+ }
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->select('*')
+ ->from(self::TAG_TABLE)
+ ->where($query->expr()->in('id', $query->createParameter('tagids')))
+ ->addOrderBy('name', 'ASC')
+ ->addOrderBy('visibility', 'ASC')
+ ->addOrderBy('editable', 'ASC')
+ ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY);
+
+ $result = $query->execute();
+ while ($row = $result->fetch()) {
+ $tag = $this->createSystemTagFromRow($row);
+ if ($user && !$this->canUserSeeTag($tag, $user)) {
+ // if a user is given, hide invisible tags
+ continue;
+ }
+ $tags[$row['id']] = $tag;
+ }
+
+ $result->closeCursor();
+
+ if (\count($tags) !== \count($tagIds)) {
+ throw new TagNotFoundException(
+ 'Tag id(s) not found', 0, null, array_diff($tagIds, array_keys($tags))
+ );
+ }
+
+ return $tags;
+ }
+
+ public function getAllTags($visibilityFilter = null, $nameSearchPattern = null): array {
+ $tags = [];
+
+ $query = $this->connection->getQueryBuilder();
+ $query->select('*')
+ ->from(self::TAG_TABLE);
+
+ if (!\is_null($visibilityFilter)) {
+ $query->andWhere($query->expr()->eq('visibility', $query->createNamedParameter((int)$visibilityFilter)));
+ }
+
+ if (!empty($nameSearchPattern)) {
+ $query->andWhere(
+ $query->expr()->iLike(
+ 'name',
+ $query->createNamedParameter('%' . $this->connection->escapeLikeParameter($nameSearchPattern) . '%')
+ )
+ );
+ }
+
+ $query
+ ->addOrderBy('name', 'ASC')
+ ->addOrderBy('visibility', 'ASC')
+ ->addOrderBy('editable', 'ASC');
+
+ $result = $query->executeQuery();
+ while ($row = $result->fetch()) {
+ $tags[$row['id']] = $this->createSystemTagFromRow($row);
+ }
+
+ $result->closeCursor();
+
+ return $tags;
+ }
+
+ public function getTag(string $tagName, bool $userVisible, bool $userAssignable): ISystemTag {
+ // Length of name column is 64
+ $truncatedTagName = substr($tagName, 0, 64);
+ $result = $this->selectTagQuery
+ ->setParameter('name', $truncatedTagName)
+ ->setParameter('visibility', $userVisible ? 1 : 0)
+ ->setParameter('editable', $userAssignable ? 1 : 0)
+ ->execute();
+
+ $row = $result->fetch();
+ $result->closeCursor();
+ if (!$row) {
+ throw new TagNotFoundException(
+ 'Tag ("' . $truncatedTagName . '", ' . $userVisible . ', ' . $userAssignable . ') does not exist'
+ );
+ }
+
+ return $this->createSystemTagFromRow($row);
+ }
+
+ public function createTag(string $tagName, bool $userVisible, bool $userAssignable): ISystemTag {
+ $user = $this->userSession->getUser();
+ if (!$this->canUserCreateTag($user)) {
+ throw new TagCreationForbiddenException();
+ }
+
+ // Check if tag already exists (case-insensitive)
+ $existingTags = $this->getAllTags(null, $tagName);
+ foreach ($existingTags as $existingTag) {
+ if (mb_strtolower($existingTag->getName()) === mb_strtolower($tagName)) {
+ throw new TagAlreadyExistsException('Tag ' . $tagName . ' already exists');
+ }
+ }
+
+ // Length of name column is 64
+ $truncatedTagName = substr($tagName, 0, 64);
+ $query = $this->connection->getQueryBuilder();
+ $query->insert(self::TAG_TABLE)
+ ->values([
+ 'name' => $query->createNamedParameter($truncatedTagName),
+ 'visibility' => $query->createNamedParameter($userVisible ? 1 : 0),
+ 'editable' => $query->createNamedParameter($userAssignable ? 1 : 0),
+ 'etag' => $query->createNamedParameter(md5((string)time())),
+ ]);
+
+ try {
+ $query->execute();
+ } catch (UniqueConstraintViolationException $e) {
+ throw new TagAlreadyExistsException(
+ 'Tag ("' . $truncatedTagName . '", ' . $userVisible . ', ' . $userAssignable . ') already exists',
+ 0,
+ $e
+ );
+ }
+
+ $tagId = $query->getLastInsertId();
+
+ $tag = new SystemTag(
+ (string)$tagId,
+ $truncatedTagName,
+ $userVisible,
+ $userAssignable
+ );
+
+ $this->dispatcher->dispatch(ManagerEvent::EVENT_CREATE, new ManagerEvent(
+ ManagerEvent::EVENT_CREATE, $tag
+ ));
+
+ return $tag;
+ }
+
+ public function updateTag(
+ string $tagId,
+ string $newName,
+ bool $userVisible,
+ bool $userAssignable,
+ ?string $color,
+ ): void {
+ try {
+ $tags = $this->getTagsByIds($tagId);
+ } catch (TagNotFoundException $e) {
+ throw new TagNotFoundException(
+ 'Tag does not exist', 0, null, [$tagId]
+ );
+ }
+
+ $user = $this->userSession->getUser();
+ if (!$this->canUserUpdateTag($user)) {
+ throw new TagUpdateForbiddenException();
+ }
+
+ $beforeUpdate = array_shift($tags);
+ // Length of name column is 64
+ $newName = trim($newName);
+ $truncatedNewName = substr($newName, 0, 64);
+ $afterUpdate = new SystemTag(
+ $tagId,
+ $truncatedNewName,
+ $userVisible,
+ $userAssignable,
+ $beforeUpdate->getETag(),
+ $color
+ );
+
+ // Check if tag already exists (case-insensitive)
+ $existingTags = $this->getAllTags(null, $truncatedNewName);
+ foreach ($existingTags as $existingTag) {
+ if (mb_strtolower($existingTag->getName()) === mb_strtolower($truncatedNewName)
+ && $existingTag->getId() !== $tagId) {
+ throw new TagAlreadyExistsException('Tag ' . $truncatedNewName . ' already exists');
+ }
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->update(self::TAG_TABLE)
+ ->set('name', $query->createParameter('name'))
+ ->set('visibility', $query->createParameter('visibility'))
+ ->set('editable', $query->createParameter('editable'))
+ ->set('color', $query->createParameter('color'))
+ ->where($query->expr()->eq('id', $query->createParameter('tagid')))
+ ->setParameter('name', $truncatedNewName)
+ ->setParameter('visibility', $userVisible ? 1 : 0)
+ ->setParameter('editable', $userAssignable ? 1 : 0)
+ ->setParameter('tagid', $tagId)
+ ->setParameter('color', $color);
+
+ try {
+ if ($query->execute() === 0) {
+ throw new TagNotFoundException(
+ 'Tag does not exist', 0, null, [$tagId]
+ );
+ }
+ } catch (UniqueConstraintViolationException $e) {
+ throw new TagAlreadyExistsException(
+ 'Tag ("' . $newName . '", ' . $userVisible . ', ' . $userAssignable . ') already exists',
+ 0,
+ $e
+ );
+ }
+
+ $this->dispatcher->dispatch(ManagerEvent::EVENT_UPDATE, new ManagerEvent(
+ ManagerEvent::EVENT_UPDATE, $afterUpdate, $beforeUpdate
+ ));
+ }
+
+ public function deleteTags($tagIds): void {
+ if (!\is_array($tagIds)) {
+ $tagIds = [$tagIds];
+ }
+
+ $tagNotFoundException = null;
+ $tags = [];
+ try {
+ $tags = $this->getTagsByIds($tagIds);
+ } catch (TagNotFoundException $e) {
+ $tagNotFoundException = $e;
+
+ // Get existing tag objects for the hooks later
+ $existingTags = array_diff($tagIds, $tagNotFoundException->getMissingTags());
+ if (!empty($existingTags)) {
+ try {
+ $tags = $this->getTagsByIds($existingTags);
+ } catch (TagNotFoundException $e) {
+ // Ignore further errors...
+ }
+ }
+ }
+
+ // delete relationships first
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(SystemTagObjectMapper::RELATION_TABLE)
+ ->where($query->expr()->in('systemtagid', $query->createParameter('tagids')))
+ ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY)
+ ->execute();
+
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(self::TAG_TABLE)
+ ->where($query->expr()->in('id', $query->createParameter('tagids')))
+ ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY)
+ ->execute();
+
+ foreach ($tags as $tag) {
+ $this->dispatcher->dispatch(ManagerEvent::EVENT_DELETE, new ManagerEvent(
+ ManagerEvent::EVENT_DELETE, $tag
+ ));
+ }
+
+ if ($tagNotFoundException !== null) {
+ throw new TagNotFoundException(
+ 'Tag id(s) not found', 0, $tagNotFoundException, $tagNotFoundException->getMissingTags()
+ );
+ }
+ }
+
+ public function canUserAssignTag(ISystemTag $tag, ?IUser $user): bool {
+ if ($user === null) {
+ return false;
+ }
+
+ // early check to avoid unneeded group lookups
+ if ($tag->isUserAssignable() && $tag->isUserVisible()) {
+ return true;
+ }
+
+ if ($this->groupManager->isAdmin($user->getUID())) {
+ return true;
+ }
+
+ if (!$tag->isUserVisible()) {
+ return false;
+ }
+
+ $groupIds = $this->groupManager->getUserGroupIds($user);
+ if (!empty($groupIds)) {
+ $matchingGroups = array_intersect($groupIds, $this->getTagGroups($tag));
+ if (!empty($matchingGroups)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function canUserCreateTag(?IUser $user): bool {
+ if ($user === null) {
+ // If no user given, allows only calls from CLI
+ return \OC::$CLI;
+ }
+
+ if ($this->appConfig->getValueBool('systemtags', 'restrict_creation_to_admin', false) === false) {
+ return true;
+ }
+
+ return $this->groupManager->isAdmin($user->getUID());
+ }
+
+ public function canUserUpdateTag(?IUser $user): bool {
+ // We currently have no different permissions for updating tags than for creating them
+ return $this->canUserCreateTag($user);
+ }
+
+ public function canUserSeeTag(ISystemTag $tag, ?IUser $user): bool {
+ // If no user, then we only show public tags
+ if (!$user && $tag->getAccessLevel() === ISystemTag::ACCESS_LEVEL_PUBLIC) {
+ return true;
+ }
+
+ if ($tag->isUserVisible()) {
+ return true;
+ }
+
+ // if not returned yet, and user is not logged in, then the tag is not visible
+ if ($user === null) {
+ return false;
+ }
+
+ if ($this->groupManager->isAdmin($user->getUID())) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private function createSystemTagFromRow($row): SystemTag {
+ return new SystemTag((string)$row['id'], $row['name'], (bool)$row['visibility'], (bool)$row['editable'], $row['etag'], $row['color']);
+ }
+
+ public function setTagGroups(ISystemTag $tag, array $groupIds): void {
+ // delete relationships first
+ $this->connection->beginTransaction();
+ try {
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(self::TAG_GROUP_TABLE)
+ ->where($query->expr()->eq('systemtagid', $query->createNamedParameter($tag->getId())))
+ ->execute();
+
+ // add each group id
+ $query = $this->connection->getQueryBuilder();
+ $query->insert(self::TAG_GROUP_TABLE)
+ ->values([
+ 'systemtagid' => $query->createNamedParameter($tag->getId()),
+ 'gid' => $query->createParameter('gid'),
+ ]);
+ foreach ($groupIds as $groupId) {
+ if ($groupId === '') {
+ continue;
+ }
+ $query->setParameter('gid', $groupId);
+ $query->execute();
+ }
+
+ $this->connection->commit();
+ } catch (\Exception $e) {
+ $this->connection->rollBack();
+ throw $e;
+ }
+ }
+
+ public function getTagGroups(ISystemTag $tag): array {
+ $groupIds = [];
+ $query = $this->connection->getQueryBuilder();
+ $query->select('gid')
+ ->from(self::TAG_GROUP_TABLE)
+ ->where($query->expr()->eq('systemtagid', $query->createNamedParameter($tag->getId())))
+ ->orderBy('gid');
+
+ $result = $query->execute();
+ while ($row = $result->fetch()) {
+ $groupIds[] = $row['gid'];
+ }
+
+ $result->closeCursor();
+
+ return $groupIds;
+ }
+
+}
diff --git a/lib/private/SystemTag/SystemTagObjectMapper.php b/lib/private/SystemTag/SystemTagObjectMapper.php
new file mode 100644
index 00000000000..1fa5975dafb
--- /dev/null
+++ b/lib/private/SystemTag/SystemTagObjectMapper.php
@@ -0,0 +1,367 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\SystemTag;
+
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IDBConnection;
+use OCP\SystemTag\ISystemTag;
+use OCP\SystemTag\ISystemTagManager;
+use OCP\SystemTag\ISystemTagObjectMapper;
+use OCP\SystemTag\MapperEvent;
+use OCP\SystemTag\TagNotFoundException;
+
+class SystemTagObjectMapper implements ISystemTagObjectMapper {
+ public const RELATION_TABLE = 'systemtag_object_mapping';
+
+ public function __construct(
+ protected IDBConnection $connection,
+ protected ISystemTagManager $tagManager,
+ protected IEventDispatcher $dispatcher,
+ ) {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTagIdsForObjects($objIds, string $objectType): array {
+ if (!\is_array($objIds)) {
+ $objIds = [$objIds];
+ } elseif (empty($objIds)) {
+ return [];
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->select(['systemtagid', 'objectid'])
+ ->from(self::RELATION_TABLE)
+ ->where($query->expr()->in('objectid', $query->createParameter('objectids')))
+ ->andWhere($query->expr()->eq('objecttype', $query->createParameter('objecttype')))
+ ->setParameter('objecttype', $objectType)
+ ->addOrderBy('objectid', 'ASC')
+ ->addOrderBy('systemtagid', 'ASC');
+ $chunks = array_chunk($objIds, 900, false);
+ $mapping = [];
+ foreach ($objIds as $objId) {
+ $mapping[$objId] = [];
+ }
+ foreach ($chunks as $chunk) {
+ $query->setParameter('objectids', $chunk, IQueryBuilder::PARAM_STR_ARRAY);
+ $result = $query->executeQuery();
+ while ($row = $result->fetch()) {
+ $objectId = $row['objectid'];
+ $mapping[$objectId][] = $row['systemtagid'];
+ }
+
+ $result->closeCursor();
+ }
+
+ return $mapping;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getObjectIdsForTags($tagIds, string $objectType, int $limit = 0, string $offset = ''): array {
+ if (!\is_array($tagIds)) {
+ $tagIds = [$tagIds];
+ }
+
+ $this->assertTagsExist($tagIds);
+
+ $query = $this->connection->getQueryBuilder();
+ $query->selectDistinct('objectid')
+ ->from(self::RELATION_TABLE)
+ ->where($query->expr()->in('systemtagid', $query->createNamedParameter($tagIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->andWhere($query->expr()->eq('objecttype', $query->createNamedParameter($objectType)));
+
+ if ($limit) {
+ if (\count($tagIds) !== 1) {
+ throw new \InvalidArgumentException('Limit is only allowed with a single tag');
+ }
+
+ $query->setMaxResults($limit)
+ ->orderBy('objectid', 'ASC');
+
+ if ($offset !== '') {
+ $query->andWhere($query->expr()->gt('objectid', $query->createNamedParameter($offset)));
+ }
+ }
+
+ $objectIds = [];
+
+ $result = $query->executeQuery();
+ while ($row = $result->fetch()) {
+ $objectIds[] = $row['objectid'];
+ }
+ $result->closeCursor();
+
+ return $objectIds;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function assignTags(string $objId, string $objectType, $tagIds): void {
+ if (!\is_array($tagIds)) {
+ $tagIds = [$tagIds];
+ }
+
+ $this->assertTagsExist($tagIds);
+ $this->connection->beginTransaction();
+
+ $query = $this->connection->getQueryBuilder();
+ $query->select('systemtagid')
+ ->from(self::RELATION_TABLE)
+ ->where($query->expr()->in('systemtagid', $query->createNamedParameter($tagIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->andWhere($query->expr()->eq('objecttype', $query->createNamedParameter($objectType)))
+ ->andWhere($query->expr()->eq('objectid', $query->createNamedParameter($objId)));
+ $result = $query->executeQuery();
+ $rows = $result->fetchAll();
+ $existingTags = [];
+ foreach ($rows as $row) {
+ $existingTags[] = $row['systemtagid'];
+ }
+ //filter only tags that do not exist in db
+ $tagIds = array_diff($tagIds, $existingTags);
+ if (empty($tagIds)) {
+ // no tags to insert so return here
+ $this->connection->commit();
+ return;
+ }
+
+ $query = $this->connection->getQueryBuilder();
+ $query->insert(self::RELATION_TABLE)
+ ->values([
+ 'objectid' => $query->createNamedParameter($objId),
+ 'objecttype' => $query->createNamedParameter($objectType),
+ 'systemtagid' => $query->createParameter('tagid'),
+ ]);
+
+ $tagsAssigned = [];
+ foreach ($tagIds as $tagId) {
+ try {
+ $query->setParameter('tagid', $tagId);
+ $query->execute();
+ $tagsAssigned[] = $tagId;
+ } catch (UniqueConstraintViolationException $e) {
+ // ignore existing relations
+ }
+ }
+
+ $this->updateEtagForTags($tagIds);
+
+ $this->connection->commit();
+ if (empty($tagsAssigned)) {
+ return;
+ }
+
+ $this->dispatcher->dispatch(MapperEvent::EVENT_ASSIGN, new MapperEvent(
+ MapperEvent::EVENT_ASSIGN,
+ $objectType,
+ $objId,
+ $tagsAssigned
+ ));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function unassignTags(string $objId, string $objectType, $tagIds): void {
+ if (!\is_array($tagIds)) {
+ $tagIds = [$tagIds];
+ }
+
+ $this->assertTagsExist($tagIds);
+
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(self::RELATION_TABLE)
+ ->where($query->expr()->eq('objectid', $query->createParameter('objectid')))
+ ->andWhere($query->expr()->eq('objecttype', $query->createParameter('objecttype')))
+ ->andWhere($query->expr()->in('systemtagid', $query->createParameter('tagids')))
+ ->setParameter('objectid', $objId)
+ ->setParameter('objecttype', $objectType)
+ ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY)
+ ->executeStatement();
+
+ $this->updateEtagForTags($tagIds);
+
+ $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent(
+ MapperEvent::EVENT_UNASSIGN,
+ $objectType,
+ $objId,
+ $tagIds
+ ));
+ }
+
+ /**
+ * Update the etag for the given tags.
+ *
+ * @param string[] $tagIds
+ */
+ private function updateEtagForTags(array $tagIds): void {
+ // Update etag after assigning tags
+ $md5 = md5(json_encode(time()));
+ $query = $this->connection->getQueryBuilder();
+ $query->update('systemtag')
+ ->set('etag', $query->createNamedParameter($md5))
+ ->where($query->expr()->in('id', $query->createNamedParameter($tagIds, IQueryBuilder::PARAM_INT_ARRAY)));
+ $query->execute();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function haveTag($objIds, string $objectType, string $tagId, bool $all = true): bool {
+ $this->assertTagsExist([$tagId]);
+
+ if (!\is_array($objIds)) {
+ $objIds = [$objIds];
+ }
+
+ $query = $this->connection->getQueryBuilder();
+
+ if (!$all) {
+ // If we only need one entry, we make the query lighter, by not
+ // counting the elements
+ $query->select('*')
+ ->setMaxResults(1);
+ } else {
+ $query->select($query->func()->count($query->expr()->literal(1)));
+ }
+
+ $query->from(self::RELATION_TABLE)
+ ->where($query->expr()->in('objectid', $query->createParameter('objectids')))
+ ->andWhere($query->expr()->eq('objecttype', $query->createParameter('objecttype')))
+ ->andWhere($query->expr()->eq('systemtagid', $query->createParameter('tagid')))
+ ->setParameter('objectids', $objIds, IQueryBuilder::PARAM_STR_ARRAY)
+ ->setParameter('tagid', $tagId)
+ ->setParameter('objecttype', $objectType);
+
+ $result = $query->executeQuery();
+ $row = $result->fetch(\PDO::FETCH_NUM);
+ $result->closeCursor();
+
+ if ($all) {
+ return ((int)$row[0] === \count($objIds));
+ }
+
+ return (bool)$row;
+ }
+
+ /**
+ * Asserts that all the given tag ids exist.
+ *
+ * @param string[] $tagIds tag ids to check
+ *
+ * @throws \OCP\SystemTag\TagNotFoundException if at least one tag did not exist
+ */
+ private function assertTagsExist(array $tagIds): void {
+ $tags = $this->tagManager->getTagsByIds($tagIds);
+ if (\count($tags) !== \count($tagIds)) {
+ // at least one tag missing, bail out
+ $foundTagIds = array_map(
+ function (ISystemTag $tag) {
+ return $tag->getId();
+ },
+ $tags
+ );
+ $missingTagIds = array_diff($tagIds, $foundTagIds);
+ throw new TagNotFoundException(
+ 'Tags not found', 0, null, $missingTagIds
+ );
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setObjectIdsForTag(string $tagId, string $objectType, array $objectIds): void {
+ $currentObjectIds = $this->getObjectIdsForTags($tagId, $objectType);
+ $removedObjectIds = array_diff($currentObjectIds, $objectIds);
+ $addedObjectIds = array_diff($objectIds, $currentObjectIds);
+
+ $this->connection->beginTransaction();
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(self::RELATION_TABLE)
+ ->where($query->expr()->eq('systemtagid', $query->createNamedParameter($tagId, IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->eq('objecttype', $query->createNamedParameter($objectType)))
+ ->executeStatement();
+ $this->connection->commit();
+
+ foreach ($removedObjectIds as $objectId) {
+ $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent(
+ MapperEvent::EVENT_UNASSIGN,
+ $objectType,
+ (string)$objectId,
+ [(int)$tagId]
+ ));
+ }
+
+ if (empty($objectIds)) {
+ return;
+ }
+
+ $this->connection->beginTransaction();
+ $query = $this->connection->getQueryBuilder();
+ $query->insert(self::RELATION_TABLE)
+ ->values([
+ 'systemtagid' => $query->createNamedParameter($tagId, IQueryBuilder::PARAM_INT),
+ 'objecttype' => $query->createNamedParameter($objectType),
+ 'objectid' => $query->createParameter('objectid'),
+ ]);
+
+ foreach (array_unique($objectIds) as $objectId) {
+ $query->setParameter('objectid', (string)$objectId);
+ $query->executeStatement();
+ }
+
+ $this->updateEtagForTags([$tagId]);
+ $this->connection->commit();
+
+ // Dispatch assign events for new object ids
+ foreach ($addedObjectIds as $objectId) {
+ $this->dispatcher->dispatch(MapperEvent::EVENT_ASSIGN, new MapperEvent(
+ MapperEvent::EVENT_ASSIGN,
+ $objectType,
+ (string)$objectId,
+ [(int)$tagId]
+ ));
+ }
+
+ // Dispatch unassign events for removed object ids
+ foreach ($removedObjectIds as $objectId) {
+ $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent(
+ MapperEvent::EVENT_UNASSIGN,
+ $objectType,
+ (string)$objectId,
+ [(int)$tagId]
+ ));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAvailableObjectTypes(): array {
+ $query = $this->connection->getQueryBuilder();
+ $query->selectDistinct('objecttype')
+ ->from(self::RELATION_TABLE);
+
+ $result = $query->executeQuery();
+ $objectTypes = [];
+ while ($row = $result->fetch()) {
+ $objectTypes[] = $row['objecttype'];
+ }
+ $result->closeCursor();
+
+ return $objectTypes;
+ }
+}
diff --git a/lib/private/SystemTag/SystemTagsInFilesDetector.php b/lib/private/SystemTag/SystemTagsInFilesDetector.php
new file mode 100644
index 00000000000..9268b7ab098
--- /dev/null
+++ b/lib/private/SystemTag/SystemTagsInFilesDetector.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\SystemTag;
+
+use OC\Files\Cache\QuerySearchHelper;
+use OC\Files\Node\Root;
+use OC\Files\Search\SearchBinaryOperator;
+use OC\Files\Search\SearchComparison;
+use OC\Files\Search\SearchQuery;
+use OCP\Files\Folder;
+use OCP\Files\Search\ISearchBinaryOperator;
+use OCP\Files\Search\ISearchComparison;
+
+class SystemTagsInFilesDetector {
+ public function __construct(
+ protected QuerySearchHelper $searchHelper,
+ ) {
+ }
+
+ public function detectAssignedSystemTagsIn(
+ Folder $folder,
+ string $filteredMediaType = '',
+ int $limit = 0,
+ int $offset = 0,
+ ): array {
+ $operator = new SearchComparison(ISearchComparison::COMPARE_LIKE, 'systemtag', '%');
+ // Currently query has to have exactly one search condition. If no media type is provided,
+ // we fall back to the presence of a system tag.
+ if ($filteredMediaType !== '') {
+ $mimeOperator = new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $filteredMediaType . '/%');
+ $operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [$operator, $mimeOperator]);
+ }
+
+ $query = new SearchQuery($operator, $limit, $offset, []);
+ [$caches, ] = $this->searchHelper->getCachesAndMountPointsForSearch(
+ $this->getRootFolder($folder),
+ $folder->getPath(),
+ );
+ return $this->searchHelper->findUsedTagsInCaches($query, $caches);
+ }
+
+ protected function getRootFolder(?Folder $folder): Root {
+ if ($folder instanceof Root) {
+ return $folder;
+ } elseif ($folder === null) {
+ throw new \LogicException('Could not climb up to root folder');
+ }
+ return $this->getRootFolder($folder->getParent());
+ }
+}