diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 34adfd28e4f9c..089b81379d2b4 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -365,6 +365,7 @@ 'OCA\\DAV\\SystemTag\\SystemTagPlugin' => $baseDir . '/../lib/SystemTag/SystemTagPlugin.php', 'OCA\\DAV\\SystemTag\\SystemTagsByIdCollection' => $baseDir . '/../lib/SystemTag/SystemTagsByIdCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsInUseCollection' => $baseDir . '/../lib/SystemTag/SystemTagsInUseCollection.php', + 'OCA\\DAV\\SystemTag\\SystemTagsObjectList' => $baseDir . '/../lib/SystemTag/SystemTagsObjectList.php', 'OCA\\DAV\\SystemTag\\SystemTagsObjectMappingCollection' => $baseDir . '/../lib/SystemTag/SystemTagsObjectMappingCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsObjectTypeCollection' => $baseDir . '/../lib/SystemTag/SystemTagsObjectTypeCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsRelationsCollection' => $baseDir . '/../lib/SystemTag/SystemTagsRelationsCollection.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 883b1fc710c4d..c3eb3cc72d276 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -380,6 +380,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\SystemTag\\SystemTagPlugin' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagPlugin.php', 'OCA\\DAV\\SystemTag\\SystemTagsByIdCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsByIdCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsInUseCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsInUseCollection.php', + 'OCA\\DAV\\SystemTag\\SystemTagsObjectList' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsObjectList.php', 'OCA\\DAV\\SystemTag\\SystemTagsObjectMappingCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsObjectMappingCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsObjectTypeCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsObjectTypeCollection.php', 'OCA\\DAV\\SystemTag\\SystemTagsRelationsCollection' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagsRelationsCollection.php', diff --git a/apps/dav/lib/SystemTag/SystemTagNode.php b/apps/dav/lib/SystemTag/SystemTagNode.php index ce84e66813bfd..e2f58b3306b96 100644 --- a/apps/dav/lib/SystemTag/SystemTagNode.php +++ b/apps/dav/lib/SystemTag/SystemTagNode.php @@ -178,7 +178,12 @@ public function childExists($name) { } public function getChildren() { - // We currently don't have a method to list allowed tag mappings types - return [new SystemTagObjectType($this->tag, 'files', $this->tagManager, $this->tagMapper)]; + $objectTypes = $this->tagMapper->getAvailableObjectTypes(); + return array_map( + function ($objectType) { + return new SystemTagObjectType($this->tag, $objectType, $this->tagManager, $this->tagMapper); + }, + $objectTypes + ); } } diff --git a/apps/dav/lib/SystemTag/SystemTagObjectType.php b/apps/dav/lib/SystemTag/SystemTagObjectType.php index 0279e4411d8f6..0e1368854cdd9 100644 --- a/apps/dav/lib/SystemTag/SystemTagObjectType.php +++ b/apps/dav/lib/SystemTag/SystemTagObjectType.php @@ -14,7 +14,7 @@ * SystemTagObjectType property * This property represent a type of object which tags are assigned to. */ -class SystemTagObjectType implements \Sabre\DAV\INode { +class SystemTagObjectType implements \Sabre\DAV\IFile { public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; /** @var string[] */ @@ -39,19 +39,43 @@ public function getObjectsIds(): array { return $this->objectsIds; } - public function delete() { - throw new MethodNotAllowed(); + /** + * Returns the system tag represented by this node + * + * @return ISystemTag system tag + */ + public function getSystemTag() { + return $this->tag; } public function getName() { return $this->type; } + public function getLastModified() { + return null; + } + + public function getETag() { + return '"' . $this->tag->getETag() . '"'; + } + public function setName($name) { throw new MethodNotAllowed(); } - - public function getLastModified() { - return null; + public function put($data) { + throw new MethodNotAllowed(); + } + public function get() { + throw new MethodNotAllowed(); + } + public function delete() { + throw new MethodNotAllowed(); + } + public function getContentType() { + throw new MethodNotAllowed(); + } + public function getSize() { + throw new MethodNotAllowed(); } } diff --git a/apps/dav/lib/SystemTag/SystemTagPlugin.php b/apps/dav/lib/SystemTag/SystemTagPlugin.php index b7671cb797b98..488394d4e4ebd 100644 --- a/apps/dav/lib/SystemTag/SystemTagPlugin.php +++ b/apps/dav/lib/SystemTag/SystemTagPlugin.php @@ -8,6 +8,7 @@ namespace OCA\DAV\SystemTag; use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\FilesPlugin; use OCA\DAV\Connector\Sabre\Node; use OCP\IGroupManager; use OCP\IUser; @@ -20,6 +21,7 @@ use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Conflict; use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\PreconditionFailed; use Sabre\DAV\Exception\UnsupportedMediaType; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; @@ -101,6 +103,9 @@ public function __construct( */ public function initialize(\Sabre\DAV\Server $server) { $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + $server->xml->namespaceMap[self::NS_NEXTCLOUD] = 'nc'; + + $server->xml->elementMap[self::OBJECTIDS_PROPERTYNAME] = SystemTagsObjectList::class; $server->protectedProperties[] = self::ID_PROPERTYNAME; @@ -234,6 +239,10 @@ public function handleGetProperties( $propFind->setPath(str_replace('systemtags-assigned/', 'systemtags/', $propFind->getPath())); } + $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, function () use ($node): string|null { + return $node->getSystemTag()->getETag(); + }); + $propFind->handle(self::ID_PROPERTYNAME, function () use ($node) { return $node->getSystemTag()->getId(); }); @@ -380,9 +389,37 @@ private function getTagsForFile(int $fileId, ?IUser $user): array { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->server->tree->getNodeForPath($path); - if (!($node instanceof SystemTagNode)) { + if (!($node instanceof SystemTagNode) && !($node instanceof SystemTagObjectType)) { return; } + + $propPatch->handle([self::OBJECTIDS_PROPERTYNAME], function ($props) use ($node) { + if (!($node instanceof SystemTagObjectType)) { + return false; + } + + if (isset($props[self::OBJECTIDS_PROPERTYNAME])) { + $propValue = $props[self::OBJECTIDS_PROPERTYNAME]; + if (!($propValue instanceof SystemTagsObjectList) || count($propValue?->getObjects() ?: []) === 0) { + throw new BadRequest('Invalid object-ids property'); + } + + $objects = $propValue->getObjects(); + $objectTypes = array_unique(array_values($objects)); + + if (count($objectTypes) !== 1 || $objectTypes[0] !== $node->getName()) { + throw new BadRequest('Invalid object-ids property. All object types must be of the same type: ' . $node->getName()); + } + + $this->tagMapper->setObjectIdsForTag($node->getSystemTag()->getId(), $node->getName(), array_keys($objects)); + } + + if ($props[self::OBJECTIDS_PROPERTYNAME] === null) { + $this->tagMapper->setObjectIdsForTag($node->getSystemTag()->getId(), $node->getName(), []); + } + + return true; + }); $propPatch->handle([ self::DISPLAYNAME_PROPERTYNAME, @@ -392,6 +429,10 @@ public function handleUpdateProperties($path, PropPatch $propPatch) { self::NUM_FILES_PROPERTYNAME, self::REFERENCE_FILEID_PROPERTYNAME, ], function ($props) use ($node) { + if (!($node instanceof SystemTagNode)) { + return false; + } + $tag = $node->getSystemTag(); $name = $tag->getName(); $userVisible = $tag->isUserVisible(); diff --git a/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php b/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php index 81abc6c156969..d2495f0d7ab26 100644 --- a/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php +++ b/apps/dav/lib/SystemTag/SystemTagsInUseCollection.php @@ -76,7 +76,7 @@ public function getChildren(): array { $result = $this->systemTagsInFilesDetector->detectAssignedSystemTagsIn($userFolder, $this->mediaType); $children = []; foreach ($result as $tagData) { - $tag = new SystemTag((string)$tagData['id'], $tagData['name'], (bool)$tagData['visibility'], (bool)$tagData['editable']); + $tag = new SystemTag((string)$tagData['id'], $tagData['name'], (bool)$tagData['visibility'], (bool)$tagData['editable'], $tagData['etag']); // read only, so we can submit the isAdmin parameter as false generally $node = new SystemTagNode($tag, $user, false, $this->systemTagManager, $this->tagMapper); $node->setNumberOfFiles((int)$tagData['number_files']); diff --git a/apps/dav/lib/SystemTag/SystemTagsObjectList.php b/apps/dav/lib/SystemTag/SystemTagsObjectList.php index e25f1c804ee6f..0743b8d813046 100644 --- a/apps/dav/lib/SystemTag/SystemTagsObjectList.php +++ b/apps/dav/lib/SystemTag/SystemTagsObjectList.php @@ -1,4 +1,5 @@ $objects An array of object ids and their types */ public function __construct( private array $objects, - ) { } + ) { + } + + public function getObjects(): array { + return $this->objects; + } + + public static function xmlDeserialize(Reader $reader) { + $tree = $reader->parseInnerTree(); + if ($tree === null) { + return null; + } + + $objects = []; + foreach ($tree as $elem) { + if ($elem['name'] === self::OBJECTID_ROOT_PROPERTYNAME) { + $value = $elem['value']; + $id = ''; + $type = ''; + foreach ($value as $subElem) { + if ($subElem['name'] === self::OBJECTID_PROPERTYNAME) { + $id = $subElem['value']; + } elseif ($subElem['name'] === self::OBJECTTYPE_PROPERTYNAME) { + $type = $subElem['value']; + } + } + if ($id !== '' && $type !== '') { + $objects[$id] = $type; + } + } + } + + return new self($objects); + } /** * The xmlSerialize method is called during xml writing. @@ -31,9 +71,9 @@ public function __construct( */ public function xmlSerialize(Writer $writer) { foreach ($this->objects as $objectsId => $type) { - $writer->startElement('{' . self::NS_NEXTCLOUD . '}object-id'); - $writer->writeElement('{' . self::NS_NEXTCLOUD . '}id', $objectsId); - $writer->writeElement('{' . self::NS_NEXTCLOUD . '}type', $type); + $writer->startElement(SystemTagPlugin::OBJECTIDS_PROPERTYNAME); + $writer->writeElement(self::OBJECTID_PROPERTYNAME, $objectsId); + $writer->writeElement(self::OBJECTTYPE_PROPERTYNAME, $type); $writer->endElement(); } } diff --git a/apps/systemtags/src/components/SystemTagPicker.vue b/apps/systemtags/src/components/SystemTagPicker.vue index 28fff28be7862..55b664e3c9aad 100644 --- a/apps/systemtags/src/components/SystemTagPicker.vue +++ b/apps/systemtags/src/components/SystemTagPicker.vue @@ -11,48 +11,70 @@ close-on-click-outside out-transition @update:open="onCancel"> - -
- - - - - {{ t('systemtags', 'Create tag') }} - -
- - -
- - {{ formatTagName(tag) }} - -
- - -
- - {{ t('systemtags', 'Select or create tags to apply to all selected files') }} - - - - -
+ + + + + + + +
+ +
@@ -63,18 +85,25 @@ import type { TagWithId } from '../types' import { defineComponent } from 'vue' import { emit } from '@nextcloud/event-bus' +import { sanitize } from 'dompurify' +import { showInfo } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' +import escapeHTML from 'escape-html' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcChip from '@nextcloud/vue/dist/Components/NcChip.js' import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' +import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' import TagIcon from 'vue-material-design-icons/Tag.vue' +import CheckIcon from 'vue-material-design-icons/CheckCircle.vue' -import logger from '../services/logger' import { getNodeSystemTags } from '../utils' -import { showInfo } from '@nextcloud/dialogs' +import { getTagObjects, setTagObjects } from '../services/api' +import logger from '../services/logger' type TagListCount = { string: number @@ -84,9 +113,14 @@ export default defineComponent({ name: 'SystemTagPicker', components: { + CheckIcon, NcButton, NcCheckboxRadioSwitch, + // eslint-disable-next-line vue/no-unused-components + NcChip, NcDialog, + NcEmptyContent, + NcLoadingIcon, NcNoteCard, NcTextField, TagIcon, @@ -113,8 +147,11 @@ export default defineComponent({ data() { return { - input: '', + done: false, + loading: false, opened: true, + + input: '', tagList: {} as TagListCount, toAdd: [] as TagWithId[], @@ -137,40 +174,44 @@ export default defineComponent({ }, statusMessage(): string { + if (this.toAdd.length === 0 && this.toRemove.length === 0) { + return '' + } + if (this.toAdd.length === 1 && this.toRemove.length === 1) { return t('systemtags', '{tag1} will be set and {tag2} will be removed from {count} files.', { - tag1: this.toAdd[0].displayName, - tag2: this.toRemove[0].displayName, + tag1: this.formatTagChip(this.toAdd[0]), + tag2: this.formatTagChip(this.toRemove[0]), count: this.nodes.length, - }) + }, undefined, { escape: false }) } - const tagsAdd = this.toAdd.map(tag => tag.displayName) + const tagsAdd = this.toAdd.map(this.formatTagChip) const lastTagAdd = tagsAdd.pop() as string - const tagsRemove = this.toRemove.map(tag => tag.displayName) + const tagsRemove = this.toRemove.map(this.formatTagChip) const lastTagRemove = tagsRemove.pop() as string const addStringSingular = t('systemtags', '{tag} will be set to {count} files.', { - tag: this.toAdd[0]?.displayName, + tag: lastTagAdd, count: this.nodes.length, - }) + }, undefined, { escape: false }) const removeStringSingular = t('systemtags', '{tag} will be removed from {count} files.', { - tag: this.toRemove[0]?.displayName, + tag: lastTagRemove, count: this.nodes.length, - }) + }, undefined, { escape: false }) const addStringPlural = t('systemtags', '{tags} and {lastTag} will be set to {count} files.', { tags: tagsAdd.join(', '), lastTag: lastTagAdd, count: this.nodes.length, - }) + }, undefined, { escape: false }) const removeStringPlural = t('systemtags', '{tags} and {lastTag} will be removed from {count} files.', { tags: tagsRemove.join(', '), lastTag: lastTagRemove, count: this.nodes.length, - }) + }, undefined, { escape: false }) // Singular if (this.toAdd.length === 1 && this.toRemove.length === 0) { @@ -213,6 +254,13 @@ export default defineComponent({ }, methods: { + // Format & sanitize a tag chip for v-html tag rendering + formatTagChip(tag: TagWithId): string { + const chip = this.$refs.chip as NcChip + const chipHtml = chip.$el.outerHTML + return chipHtml.replace('%s', escapeHTML(sanitize(tag.displayName))) + }, + formatTagName(tag: TagWithId): string { if (tag.userVisible) { return t('systemtags', '{displayName} (hidden)', { displayName: tag.displayName }) @@ -226,11 +274,12 @@ export default defineComponent({ }, isChecked(tag: TagWithId): boolean { - return this.tagList[tag.displayName] === this.nodes.length + return tag.displayName in this.tagList + && this.tagList[tag.displayName] === this.nodes.length }, isIndeterminate(tag: TagWithId): boolean { - return this.tagList[tag.displayName] + return tag.displayName in this.tagList && this.tagList[tag.displayName] !== 0 && this.tagList[tag.displayName] !== this.nodes.length }, @@ -247,9 +296,37 @@ export default defineComponent({ } }, - onSubmit() { - logger.debug('onSubmit') - this.$emit('close', null) + async onSubmit() { + this.loading = true + logger.debug('Applying tags', { + toAdd: this.toAdd, + toRemove: this.toRemove, + }) + + // Add tags + for (const tag of this.toAdd) { + const { etag, objects } = await getTagObjects(tag, 'files') + let ids = [...objects.map(obj => obj.id), ...this.nodes.map(node => node.fileid)] as number[] + // Remove duplicates and empty ids + ids = [...new Set(ids.filter(id => !!id))] + await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag) + } + + // Remove tags + for (const tag of this.toRemove) { + const { etag, objects } = await getTagObjects(tag, 'files') + let ids = objects.map(obj => obj.id) as number[] + // Remove the ids of the nodes and remove duplicates + ids = [...new Set(ids.filter(id => !this.nodes.map(node => node.fileid).includes(id)))] + await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag) + } + + this.done = true + this.loading = false + setTimeout(() => { + this.opened = false + this.$emit('close', null) + }, 2000) }, onCancel() { @@ -291,4 +368,8 @@ export default defineComponent({ } } +// Rendered chip in note +.nc-chip { + display: inline !important; +} diff --git a/apps/systemtags/src/services/api.ts b/apps/systemtags/src/services/api.ts index 1e2c9aeb9d421..c37b6e51c202d 100644 --- a/apps/systemtags/src/services/api.ts +++ b/apps/systemtags/src/services/api.ts @@ -15,7 +15,7 @@ import { formatTag, parseIdFromLocation, parseTags } from '../utils' import { logger } from '../logger.js' export const fetchTagsPayload = ` - + @@ -79,7 +79,7 @@ export const createTag = async (tag: Tag | ServerTag): Promise => { export const updateTag = async (tag: TagWithId): Promise => { const path = '/systemtags/' + tag.id const data = ` - + ${tag.displayName} @@ -109,3 +109,68 @@ export const deleteTag = async (tag: TagWithId): Promise => { throw new Error(t('systemtags', 'Failed to delete tag')) } } + +type TagObject = { + id: number, + type: string, +} + +type TagObjectResponse = { + etag: string, + objects: TagObject[], +} + +export const getTagObjects = async function(tag: TagWithId, type: string): Promise { + const path = `/systemtags/${tag.id}/${type}` + const data = ` + + + + + + ` + + const response = await davClient.stat(path, { data, details: true }) + const etag = response?.data?.props?.getetag || '""' + const objects = Object.values(response?.data?.props?.['object-ids'] || []).flat() as TagObject[] + + return { + etag, + objects, + } +} + +/** + * Set the objects for a tag. + * Warning: This will overwrite the existing objects. + */ +export const setTagObjects = async function(tag: TagWithId, type: string, objectIds: TagObject[], etag: string = ''): Promise { + const path = `/systemtags/${tag.id}/${type}` + let data = ` + + + + ${objectIds.map(({ id, type }) => `${id}${type}`).join('')} + + + ` + + if (objectIds.length === 0) { + data = ` + + + + + + + ` + } + + await davClient.customRequest(path, { + method: 'PROPPATCH', + data, + headers: { + 'if-match': etag, + }, + }) +} diff --git a/core/Migrations/Version31000Date20241018063111.php b/core/Migrations/Version31000Date20241018063111.php index eda5c5c40f9de..b9326f388e59d 100644 --- a/core/Migrations/Version31000Date20241018063111.php +++ b/core/Migrations/Version31000Date20241018063111.php @@ -37,6 +37,17 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt } } + if ($schema->hasTable('systemtag')) { + $table = $schema->getTable('systemtag'); + + if (!$table->hasColumn('etag')) { + $table->addColumn('etag', 'string', [ + 'notnull' => false, + 'length' => 32, + ]); + } + } + return $schema; } } diff --git a/lib/private/Files/Cache/CacheQueryBuilder.php b/lib/private/Files/Cache/CacheQueryBuilder.php index 76eb2bfa5ca89..2853ca033add0 100644 --- a/lib/private/Files/Cache/CacheQueryBuilder.php +++ b/lib/private/Files/Cache/CacheQueryBuilder.php @@ -28,7 +28,7 @@ public function __construct( public function selectTagUsage(): self { $this - ->select('systemtag.name', 'systemtag.id', 'systemtag.visibility', 'systemtag.editable') + ->select('systemtag.name', 'systemtag.id', 'systemtag.visibility', 'systemtag.editable', 'systemtag.etag') ->selectAlias($this->createFunction('COUNT(filecache.fileid)'), 'number_files') ->selectAlias($this->createFunction('MAX(filecache.fileid)'), 'ref_file_id') ->from('filecache', 'filecache') diff --git a/lib/private/SystemTag/SystemTag.php b/lib/private/SystemTag/SystemTag.php index 83fb6bc7f93e6..be97bed27ddb6 100644 --- a/lib/private/SystemTag/SystemTag.php +++ b/lib/private/SystemTag/SystemTag.php @@ -16,6 +16,7 @@ public function __construct( private string $name, private bool $userVisible, private bool $userAssignable, + private ?string $etag = null, ) { } @@ -61,4 +62,11 @@ public function getAccessLevel(): int { return self::ACCESS_LEVEL_PUBLIC; } + + /** + * {@inheritdoc} + */ + public function getEtag(): ?string { + return $this->etag; + } } diff --git a/lib/private/SystemTag/SystemTagManager.php b/lib/private/SystemTag/SystemTagManager.php index cbba9968656f4..70bb8e6e70b25 100644 --- a/lib/private/SystemTag/SystemTagManager.php +++ b/lib/private/SystemTag/SystemTagManager.php @@ -165,6 +165,7 @@ public function createTag(string $tagName, bool $userVisible, bool $userAssignab 'name' => $query->createNamedParameter($truncatedTagName), 'visibility' => $query->createNamedParameter($userVisible ? 1 : 0), 'editable' => $query->createNamedParameter($userAssignable ? 1 : 0), + 'etag' => $query->createNamedParameter(md5((string)time())), ]); try { @@ -360,7 +361,7 @@ public function canUserSeeTag(ISystemTag $tag, ?IUser $user): bool { } private function createSystemTagFromRow($row): SystemTag { - return new SystemTag((string)$row['id'], $row['name'], (bool)$row['visibility'], (bool)$row['editable']); + return new SystemTag((string)$row['id'], $row['name'], (bool)$row['visibility'], (bool)$row['editable'], $row['etag']); } /** diff --git a/lib/private/SystemTag/SystemTagObjectMapper.php b/lib/private/SystemTag/SystemTagObjectMapper.php index e453a1eaf1264..7d36e771997aa 100644 --- a/lib/private/SystemTag/SystemTagObjectMapper.php +++ b/lib/private/SystemTag/SystemTagObjectMapper.php @@ -156,6 +156,8 @@ public function assignTags(string $objId, string $objectType, $tagIds): void { } } + $this->updateEtagForTags($tagIds); + $this->connection->commit(); if (empty($tagsAssigned)) { return; @@ -189,6 +191,8 @@ public function unassignTags(string $objId, string $objectType, $tagIds): void { ->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY) ->execute(); + $this->updateEtagForTags($tagIds); + $this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent( MapperEvent::EVENT_UNASSIGN, $objectType, @@ -197,6 +201,21 @@ public function unassignTags(string $objId, string $objectType, $tagIds): void { )); } + /** + * Update the etag for the given tags. + * + * @param int[] $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} */ @@ -261,6 +280,43 @@ function (ISystemTag $tag) { } } + /** + * {@inheritdoc} + */ + public function setObjectIdsForTag(string $tagId, string $objectType, array $objectIds): void { + $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(); + + 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(); + } + + /** + * {@inheritdoc} + */ public function getAvailableObjectTypes(): array { $query = $this->connection->getQueryBuilder(); $query->selectDistinct('objecttype') diff --git a/lib/public/SystemTag/ISystemTag.php b/lib/public/SystemTag/ISystemTag.php index 8a01779f78330..77575494d16c6 100644 --- a/lib/public/SystemTag/ISystemTag.php +++ b/lib/public/SystemTag/ISystemTag.php @@ -80,4 +80,13 @@ public function isUserAssignable(): bool; * @since 22.0.0 */ public function getAccessLevel(): int; + + /** + * Returns the ETag of the tag + * The ETag is a unique identifier for the tag and should change whenever the tag changes + * or whenever elements gets added or removed from the tag. + * + * @since 31.0.0 + */ + public function getEtag(): ?string; } diff --git a/lib/public/SystemTag/ISystemTagObjectMapper.php b/lib/public/SystemTag/ISystemTagObjectMapper.php index cd4c3495171bb..96e8c1e848a2f 100644 --- a/lib/public/SystemTag/ISystemTagObjectMapper.php +++ b/lib/public/SystemTag/ISystemTagObjectMapper.php @@ -121,4 +121,17 @@ public function haveTag($objIds, string $objectType, string $tagId, bool $all = * @since 31.0.0 */ public function getAvailableObjectTypes(): array; + + /** + * Set the list of object ids for the given tag. + * This will replace the current list of object ids. + * + * @param string $tagId tag id + * @param string $objectType object type + * @param string[] $objectIds list of object ids + * + * @throws TagNotFoundException if the tag does not exist + * @since 31.0.0 + */ + public function setObjectIdsForTag(string $tagId, string $objectType, array $objectIds): void; }