mirror of
https://github.com/nextcloud/server.git
synced 2026-04-15 22:11:17 -04:00
feat(systemtags): add etag support and handle proppatch
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
This commit is contained in:
parent
f24b93e506
commit
14e2a8d3f9
16 changed files with 431 additions and 75 deletions
|
|
@ -366,6 +366,7 @@ return array(
|
|||
'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',
|
||||
|
|
|
|||
|
|
@ -381,6 +381,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',
|
||||
|
|
|
|||
|
|
@ -184,7 +184,12 @@ class SystemTagNode implements \Sabre\DAV\ICollection {
|
|||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use Sabre\DAV\Exception\MethodNotAllowed;
|
|||
* 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 @@ class SystemTagObjectType implements \Sabre\DAV\INode {
|
|||
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 setName($name) {
|
||||
throw new MethodNotAllowed();
|
||||
}
|
||||
|
||||
public function getLastModified() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getETag() {
|
||||
return '"' . $this->tag->getETag() . '"';
|
||||
}
|
||||
|
||||
public function setName($name) {
|
||||
throw new MethodNotAllowed();
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 OCP\Util;
|
|||
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;
|
||||
|
|
@ -80,6 +82,9 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
|
|||
*/
|
||||
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;
|
||||
|
||||
|
|
@ -213,6 +218,10 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
|
|||
$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();
|
||||
});
|
||||
|
|
@ -359,9 +368,37 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
|
|||
*/
|
||||
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,
|
||||
|
|
@ -371,6 +408,10 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class SystemTagsInUseCollection extends SimpleCollection {
|
|||
$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']);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
|
|
@ -7,21 +8,60 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace OCA\DAV\SystemTag;
|
||||
|
||||
use Sabre\Xml\Reader;
|
||||
use Sabre\Xml\Writer;
|
||||
use Sabre\Xml\XmlDeserializable;
|
||||
use Sabre\Xml\XmlSerializable;
|
||||
|
||||
/**
|
||||
* This property contains multiple "object-id" elements.
|
||||
*/
|
||||
class SystemTagsObjectList implements XmlSerializable {
|
||||
class SystemTagsObjectList implements XmlSerializable, XmlDeserializable {
|
||||
|
||||
public const NS_NEXTCLOUD = 'http://nextcloud.org/ns';
|
||||
public const OBJECTID_ROOT_PROPERTYNAME = '{http://nextcloud.org/ns}object-id';
|
||||
public const OBJECTID_PROPERTYNAME = '{http://nextcloud.org/ns}id';
|
||||
public const OBJECTTYPE_PROPERTYNAME = '{http://nextcloud.org/ns}type';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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 @@ class SystemTagsObjectList implements XmlSerializable {
|
|||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,48 +11,70 @@
|
|||
close-on-click-outside
|
||||
out-transition
|
||||
@update:open="onCancel">
|
||||
<!-- Search or create input -->
|
||||
<div class="systemtags-picker__create">
|
||||
<NcTextField :value.sync="input"
|
||||
:label="t('systemtags', 'Search or create tag')">
|
||||
<TagIcon :size="20" />
|
||||
</NcTextField>
|
||||
<NcButton>
|
||||
{{ t('systemtags', 'Create tag') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<NcEmptyContent v-if="loading || done" :name="t('systemtags', 'Applying changes…')">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="!done" />
|
||||
<CheckIcon v-else fill-color="var(--color-success)" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Tags list -->
|
||||
<div class="systemtags-picker__tags">
|
||||
<NcCheckboxRadioSwitch v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
:label="tag.displayName"
|
||||
:checked="isChecked(tag)"
|
||||
:indeterminate="isIndeterminate(tag)"
|
||||
:disabled="!tag.canAssign"
|
||||
@update:checked="onCheckUpdate(tag, $event)">
|
||||
{{ formatTagName(tag) }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Search or create input -->
|
||||
<div class="systemtags-picker__create">
|
||||
<NcTextField :value.sync="input"
|
||||
:label="t('systemtags', 'Search or create tag')">
|
||||
<TagIcon :size="20" />
|
||||
</NcTextField>
|
||||
<NcButton>
|
||||
{{ t('systemtags', 'Create tag') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Note -->
|
||||
<div class="systemtags-picker__note">
|
||||
<NcNoteCard v-if="!hasChanges" type="info">
|
||||
{{ t('systemtags', 'Select or create tags to apply to all selected files') }}
|
||||
</NcNoteCard>
|
||||
<NcNoteCard v-else type="info">
|
||||
<span v-html="statusMessage" />
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
<!-- Tags list -->
|
||||
<div v-if="filteredTags.length > 0" class="systemtags-picker__tags">
|
||||
<NcCheckboxRadioSwitch v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
:label="tag.displayName"
|
||||
:checked="isChecked(tag)"
|
||||
:indeterminate="isIndeterminate(tag)"
|
||||
:disabled="!tag.canAssign"
|
||||
@update:checked="onCheckUpdate(tag, $event)">
|
||||
{{ formatTagName(tag) }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<NcEmptyContent v-else :name="t('systemtags', 'No tags found')">
|
||||
<template #icon>
|
||||
<TagIcon />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Note -->
|
||||
<div class="systemtags-picker__note">
|
||||
<NcNoteCard v-if="!hasChanges" type="info">
|
||||
{{ t('systemtags', 'Select or create tags to apply to all selected files') }}
|
||||
</NcNoteCard>
|
||||
<NcNoteCard v-else type="info">
|
||||
<span v-html="statusMessage" />
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<NcButton type="tertiary" @click="onCancel">
|
||||
<NcButton :disabled="loading || done" type="tertiary" @click="onCancel">
|
||||
{{ t('systemtags', 'Cancel') }}
|
||||
</NcButton>
|
||||
<NcButton :disabled="!hasChanges" @click="onSubmit">
|
||||
<NcButton :disabled="!hasChanges || loading || done" @click="onSubmit">
|
||||
{{ t('systemtags', 'Apply changes') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<!-- Chip html for v-html tag rendering -->
|
||||
<div v-show="false">
|
||||
<NcChip ref="chip"
|
||||
text="%s"
|
||||
type="primary"
|
||||
no-close />
|
||||
</div>
|
||||
</NcDialog>
|
||||
</template>
|
||||
|
||||
|
|
@ -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 === 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,
|
||||
count: this.nodes.length,
|
||||
})
|
||||
if (this.toAdd.length === 0 && this.toRemove.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const tagsAdd = this.toAdd.map(tag => tag.displayName)
|
||||
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.formatTagChip(this.toAdd[0]),
|
||||
tag2: this.formatTagChip(this.toRemove[0]),
|
||||
count: this.nodes.length,
|
||||
}, undefined, { escape: false })
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { formatTag, parseIdFromLocation, parseTags } from '../utils'
|
|||
import { logger } from '../logger.js'
|
||||
|
||||
export const fetchTagsPayload = `<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:id />
|
||||
<oc:display-name />
|
||||
|
|
@ -79,7 +79,7 @@ export const createTag = async (tag: Tag | ServerTag): Promise<number> => {
|
|||
export const updateTag = async (tag: TagWithId): Promise<void> => {
|
||||
const path = '/systemtags/' + tag.id
|
||||
const data = `<?xml version="1.0"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<oc:display-name>${tag.displayName}</oc:display-name>
|
||||
|
|
@ -109,3 +109,68 @@ export const deleteTag = async (tag: TagWithId): Promise<void> => {
|
|||
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<TagObjectResponse> {
|
||||
const path = `/systemtags/${tag.id}/${type}`
|
||||
const data = `<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<nc:object-ids />
|
||||
<d:getetag />
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
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<void> {
|
||||
const path = `/systemtags/${tag.id}/${type}`
|
||||
let data = `<?xml version="1.0"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<nc:object-ids>${objectIds.map(({ id, type }) => `<nc:object-id><nc:id>${id}</nc:id><nc:type>${type}</nc:type></nc:object-id>`).join('')}</nc:object-ids>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:propertyupdate>`
|
||||
|
||||
if (objectIds.length === 0) {
|
||||
data = `<?xml version="1.0"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:remove>
|
||||
<d:prop>
|
||||
<nc:object-ids />
|
||||
</d:prop>
|
||||
</d:remove>
|
||||
</d:propertyupdate>`
|
||||
}
|
||||
|
||||
await davClient.customRequest(path, {
|
||||
method: 'PROPPATCH',
|
||||
data,
|
||||
headers: {
|
||||
'if-match': etag,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,17 @@ class Version31000Date20241018063111 extends SimpleMigrationStep {
|
|||
}
|
||||
}
|
||||
|
||||
if ($schema->hasTable('systemtag')) {
|
||||
$table = $schema->getTable('systemtag');
|
||||
|
||||
if (!$table->hasColumn('etag')) {
|
||||
$table->addColumn('etag', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 32,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class CacheQueryBuilder extends ExtendedQueryBuilder {
|
|||
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class SystemTag implements ISystemTag {
|
|||
private string $name,
|
||||
private bool $userVisible,
|
||||
private bool $userAssignable,
|
||||
private ?string $etag = null,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -61,4 +62,11 @@ class SystemTag implements ISystemTag {
|
|||
|
||||
return self::ACCESS_LEVEL_PUBLIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEtag(): ?string {
|
||||
return $this->etag;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ class SystemTagManager implements ISystemTagManager {
|
|||
'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 @@ class SystemTagManager implements ISystemTagManager {
|
|||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ class SystemTagObjectMapper implements ISystemTagObjectMapper {
|
|||
}
|
||||
}
|
||||
|
||||
$this->updateEtagForTags($tagIds);
|
||||
|
||||
$this->connection->commit();
|
||||
if (empty($tagsAssigned)) {
|
||||
return;
|
||||
|
|
@ -189,6 +191,8 @@ class SystemTagObjectMapper implements ISystemTagObjectMapper {
|
|||
->setParameter('tagids', $tagIds, IQueryBuilder::PARAM_INT_ARRAY)
|
||||
->executeStatement();
|
||||
|
||||
$this->updateEtagForTags($tagIds);
|
||||
|
||||
$this->dispatcher->dispatch(MapperEvent::EVENT_UNASSIGN, new MapperEvent(
|
||||
MapperEvent::EVENT_UNASSIGN,
|
||||
$objectType,
|
||||
|
|
@ -197,6 +201,21 @@ class SystemTagObjectMapper implements ISystemTagObjectMapper {
|
|||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 @@ class SystemTagObjectMapper implements ISystemTagObjectMapper {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@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')
|
||||
|
|
|
|||
|
|
@ -80,4 +80,13 @@ interface ISystemTag {
|
|||
* @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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,4 +121,17 @@ interface ISystemTagObjectMapper {
|
|||
* @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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue