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') }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('systemtags', 'Create tag') }}
+
+
+
+
+
+
+ {{ formatTagName(tag) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('systemtags', 'Select or create tags to apply to all selected files') }}
+
+
+
+
+
+
-
+
{{ t('systemtags', 'Cancel') }}
-
+
{{ t('systemtags', 'Apply changes') }}
+
+
+
+
+
@@ -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;
}