Merge pull request #48786 from nextcloud/feat/files-bulk-tagging

This commit is contained in:
John Molakvoæ 2024-10-29 11:20:28 +01:00 committed by GitHub
commit 73fdf2c150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 2188 additions and 253 deletions

View file

@ -362,9 +362,11 @@ return array(
'OCA\\DAV\\SystemTag\\SystemTagList' => $baseDir . '/../lib/SystemTag/SystemTagList.php',
'OCA\\DAV\\SystemTag\\SystemTagMappingNode' => $baseDir . '/../lib/SystemTag/SystemTagMappingNode.php',
'OCA\\DAV\\SystemTag\\SystemTagNode' => $baseDir . '/../lib/SystemTag/SystemTagNode.php',
'OCA\\DAV\\SystemTag\\SystemTagObjectType' => $baseDir . '/../lib/SystemTag/SystemTagObjectType.php',
'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',

View file

@ -377,9 +377,11 @@ class ComposerStaticInitDAV
'OCA\\DAV\\SystemTag\\SystemTagList' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagList.php',
'OCA\\DAV\\SystemTag\\SystemTagMappingNode' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagMappingNode.php',
'OCA\\DAV\\SystemTag\\SystemTagNode' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagNode.php',
'OCA\\DAV\\SystemTag\\SystemTagObjectType' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagObjectType.php',
'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',

View file

@ -105,11 +105,7 @@ class RootCollection extends SimpleCollection {
$publicCalendarRoot = new PublicCalendarRoot($caldavBackend, $l10n, $config, $logger);
$systemTagCollection = new SystemTagsByIdCollection(
\OC::$server->getSystemTagManager(),
\OC::$server->getUserSession(),
$groupManager
);
$systemTagCollection = Server::get(SystemTagsByIdCollection::class);
$systemTagRelationsCollection = new SystemTagsRelationsCollection(
\OC::$server->getSystemTagManager(),
\OC::$server->getSystemTagObjectMapper(),

View file

@ -10,8 +10,8 @@ namespace OCA\DAV\SystemTag;
use OCP\IUser;
use OCP\SystemTag\ISystemTag;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
use OCP\SystemTag\TagAlreadyExistsException;
use OCP\SystemTag\TagNotFoundException;
use Sabre\DAV\Exception\Conflict;
use Sabre\DAV\Exception\Forbidden;
@ -21,7 +21,7 @@ use Sabre\DAV\Exception\NotFound;
/**
* DAV node representing a system tag, with the name being the tag id.
*/
class SystemTagNode implements \Sabre\DAV\INode {
class SystemTagNode implements \Sabre\DAV\ICollection {
protected int $numberOfFiles = -1;
protected int $referenceFileId = -1;
@ -43,8 +43,9 @@ class SystemTagNode implements \Sabre\DAV\INode {
/**
* Whether to allow permissions for admins
*/
protected $isAdmin,
protected bool $isAdmin,
protected ISystemTagManager $tagManager,
protected ISystemTagObjectMapper $tagMapper,
) {
}
@ -164,4 +165,31 @@ class SystemTagNode implements \Sabre\DAV\INode {
public function setReferenceFileId(int $referenceFileId): void {
$this->referenceFileId = $referenceFileId;
}
public function createFile($name, $data = null) {
throw new MethodNotAllowed();
}
public function createDirectory($name) {
throw new MethodNotAllowed();
}
public function getChild($name) {
return new SystemTagObjectType($this->tag, $name, $this->tagManager, $this->tagMapper);
}
public function childExists($name) {
$objectTypes = $this->tagMapper->getAvailableObjectTypes();
return in_array($name, $objectTypes);
}
public function getChildren() {
$objectTypes = $this->tagMapper->getAvailableObjectTypes();
return array_map(
function ($objectType) {
return new SystemTagObjectType($this->tag, $objectType, $this->tagManager, $this->tagMapper);
},
$objectTypes
);
}
}

View file

@ -0,0 +1,81 @@
<?php
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\SystemTag;
use OCP\SystemTag\ISystemTag;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
use Sabre\DAV\Exception\MethodNotAllowed;
/**
* SystemTagObjectType property
* This property represent a type of object which tags are assigned to.
*/
class SystemTagObjectType implements \Sabre\DAV\IFile {
public const NS_NEXTCLOUD = 'http://nextcloud.org/ns';
/** @var string[] */
private array $objectsIds = [];
public function __construct(
private ISystemTag $tag,
private string $type,
private ISystemTagManager $tagManager,
private ISystemTagObjectMapper $tagMapper,
) {
}
/**
* Get the list of object ids that have this tag assigned.
*/
public function getObjectsIds(): array {
if (empty($this->objectsIds)) {
$this->objectsIds = $this->tagMapper->getObjectIdsForTags($this->tag->getId(), $this->type);
}
return $this->objectsIds;
}
/**
* 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 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();
}
}

View file

@ -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;
@ -37,6 +38,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
// namespace
public const NS_OWNCLOUD = 'http://owncloud.org/ns';
public const NS_NEXTCLOUD = 'http://nextcloud.org/ns';
public const ID_PROPERTYNAME = '{http://owncloud.org/ns}id';
public const DISPLAYNAME_PROPERTYNAME = '{http://owncloud.org/ns}display-name';
public const USERVISIBLE_PROPERTYNAME = '{http://owncloud.org/ns}user-visible';
@ -45,7 +47,8 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
public const CANASSIGN_PROPERTYNAME = '{http://owncloud.org/ns}can-assign';
public const SYSTEM_TAGS_PROPERTYNAME = '{http://nextcloud.org/ns}system-tags';
public const NUM_FILES_PROPERTYNAME = '{http://nextcloud.org/ns}files-assigned';
public const FILEID_PROPERTYNAME = '{http://nextcloud.org/ns}reference-fileid';
public const REFERENCE_FILEID_PROPERTYNAME = '{http://nextcloud.org/ns}reference-fileid';
public const OBJECTIDS_PROPERTYNAME = '{http://nextcloud.org/ns}object-ids';
/**
* @var \Sabre\DAV\Server $server
@ -78,6 +81,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;
@ -202,7 +208,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
return;
}
if (!($node instanceof SystemTagNode) && !($node instanceof SystemTagMappingNode)) {
if (!($node instanceof SystemTagNode) && !($node instanceof SystemTagMappingNode) && !($node instanceof SystemTagObjectType)) {
return;
}
@ -211,6 +217,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 {
return '"' . ($node->getSystemTag()->getETag() ?? '') . '"';
});
$propFind->handle(self::ID_PROPERTYNAME, function () use ($node) {
return $node->getSystemTag()->getId();
});
@ -251,9 +261,25 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
return $node->getNumberOfFiles();
});
$propFind->handle(self::FILEID_PROPERTYNAME, function () use ($node): int {
$propFind->handle(self::REFERENCE_FILEID_PROPERTYNAME, function () use ($node): int {
return $node->getReferenceFileId();
});
$propFind->handle(self::OBJECTIDS_PROPERTYNAME, function () use ($node): SystemTagsObjectList {
$objectTypes = $this->tagMapper->getAvailableObjectTypes();
$objects = [];
foreach ($objectTypes as $type) {
$systemTagObjectType = new SystemTagObjectType($node->getSystemTag(), $type, $this->tagManager, $this->tagMapper);
$objects = array_merge($objects, array_fill_keys($systemTagObjectType->getObjectsIds(), $type));
}
return new SystemTagsObjectList($objects);
});
}
if ($node instanceof SystemTagObjectType) {
$propFind->handle(self::OBJECTIDS_PROPERTYNAME, function () use ($node): SystemTagsObjectList {
return new SystemTagsObjectList(array_fill_keys($node->getObjectsIds(), $node->getName()));
});
}
}
@ -341,9 +367,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,
@ -351,8 +405,12 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
self::USERASSIGNABLE_PROPERTYNAME,
self::GROUPS_PROPERTYNAME,
self::NUM_FILES_PROPERTYNAME,
self::FILEID_PROPERTYNAME,
self::REFERENCE_FILEID_PROPERTYNAME,
], function ($props) use ($node) {
if (!($node instanceof SystemTagNode)) {
return false;
}
$tag = $node->getSystemTag();
$name = $tag->getName();
$userVisible = $tag->isUserVisible();
@ -388,7 +446,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin {
$this->tagManager->setTagGroups($tag, $groupIds);
}
if (isset($props[self::NUM_FILES_PROPERTYNAME]) || isset($props[self::FILEID_PROPERTYNAME])) {
if (isset($props[self::NUM_FILES_PROPERTYNAME]) || isset($props[self::REFERENCE_FILEID_PROPERTYNAME])) {
// read-only properties
throw new Forbidden();
}

View file

@ -11,6 +11,7 @@ use OCP\IGroupManager;
use OCP\IUserSession;
use OCP\SystemTag\ISystemTag;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
use OCP\SystemTag\TagNotFoundException;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Forbidden;
@ -30,6 +31,7 @@ class SystemTagsByIdCollection implements ICollection {
private ISystemTagManager $tagManager,
private IUserSession $userSession,
private IGroupManager $groupManager,
protected ISystemTagObjectMapper $tagMapper,
) {
}
@ -162,6 +164,6 @@ class SystemTagsByIdCollection implements ICollection {
* @return SystemTagNode
*/
private function makeNode(ISystemTag $tag) {
return new SystemTagNode($tag, $this->userSession->getUser(), $this->isAdmin(), $this->tagManager);
return new SystemTagNode($tag, $this->userSession->getUser(), $this->isAdmin(), $this->tagManager, $this->tagMapper);
}
}

View file

@ -16,6 +16,7 @@ use OCP\Files\IRootFolder;
use OCP\Files\NotPermittedException;
use OCP\IUserSession;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\SimpleCollection;
@ -28,6 +29,7 @@ class SystemTagsInUseCollection extends SimpleCollection {
protected IUserSession $userSession,
protected IRootFolder $rootFolder,
protected ISystemTagManager $systemTagManager,
protected ISystemTagObjectMapper $tagMapper,
SystemTagsInFilesDetector $systemTagsInFilesDetector,
protected string $mediaType = '',
) {
@ -46,7 +48,7 @@ class SystemTagsInUseCollection extends SimpleCollection {
if ($this->mediaType !== '') {
throw new NotFound('Invalid media type');
}
return new self($this->userSession, $this->rootFolder, $this->systemTagManager, $this->systemTagsInFilesDetector, $name);
return new self($this->userSession, $this->rootFolder, $this->systemTagManager, $this->tagMapper, $this->systemTagsInFilesDetector, $name);
}
/**
@ -71,9 +73,9 @@ 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);
$node = new SystemTagNode($tag, $user, false, $this->systemTagManager, $this->tagMapper);
$node->setNumberOfFiles((int)$tagData['number_files']);
$node->setReferenceFileId((int)$tagData['ref_file_id']);
$children[] = $node;

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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, 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.
*
* @param Writer $writer
* @return void
*/
public function xmlSerialize(Writer $writer) {
foreach ($this->objects as $objectsId => $type) {
$writer->startElement(SystemTagPlugin::OBJECTIDS_PROPERTYNAME);
$writer->writeElement(self::OBJECTID_PROPERTYNAME, $objectsId);
$writer->writeElement(self::OBJECTTYPE_PROPERTYNAME, $type);
$writer->endElement();
}
}
}

View file

@ -12,6 +12,7 @@ use OCA\DAV\SystemTag\SystemTagNode;
use OCP\IUser;
use OCP\SystemTag\ISystemTag;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
use OCP\SystemTag\TagAlreadyExistsException;
use OCP\SystemTag\TagNotFoundException;
use Sabre\DAV\Exception\Forbidden;
@ -23,6 +24,11 @@ class SystemTagNodeTest extends \Test\TestCase {
*/
private $tagManager;
/**
* @var ISystemTagObjectMapper|\PHPUnit\Framework\MockObject\MockObject
*/
private $tagMapper;
/**
* @var IUser
*/
@ -33,6 +39,8 @@ class SystemTagNodeTest extends \Test\TestCase {
$this->tagManager = $this->getMockBuilder(ISystemTagManager::class)
->getMock();
$this->tagMapper = $this->getMockBuilder(ISystemTagObjectMapper::class)
->getMock();
$this->user = $this->getMockBuilder(IUser::class)
->getMock();
}
@ -45,7 +53,8 @@ class SystemTagNodeTest extends \Test\TestCase {
$tag,
$this->user,
$isAdmin,
$this->tagManager
$this->tagManager,
$this->tagMapper,
);
}

View file

@ -13,7 +13,9 @@ use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserSession;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
use OCP\SystemTag\TagNotFoundException;
use PHPUnit\Framework\MockObject\MockObject;
class SystemTagsByIdCollectionTest extends \Test\TestCase {
@ -40,21 +42,31 @@ class SystemTagsByIdCollectionTest extends \Test\TestCase {
$this->user->expects($this->any())
->method('getUID')
->willReturn('testuser');
/** @var IUserSession|MockObject */
$userSession = $this->getMockBuilder(IUserSession::class)
->getMock();
$userSession->expects($this->any())
->method('getUser')
->willReturn($this->user);
/** @var IGroupManager|MockObject */
$groupManager = $this->getMockBuilder(IGroupManager::class)
->getMock();
$groupManager->expects($this->any())
->method('isAdmin')
->with('testuser')
->willReturn($isAdmin);
/** @var ISystemTagObjectMapper|MockObject */
$tagMapper = $this->getMockBuilder(ISystemTagObjectMapper::class)
->getMock();
return new SystemTagsByIdCollection(
$this->tagManager,
$userSession,
$groupManager
$groupManager,
$tagMapper,
);
}

View file

@ -9,6 +9,7 @@
<NcCheckboxRadioSwitch v-else
:aria-label="ariaLabel"
:checked="isSelected"
data-cy-files-list-row-checkbox
@update:checked="onSelectionChange" />
</td>
</template>

View file

@ -6,7 +6,7 @@
<tr class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox"
@keyup.esc.exact="resetSelection">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
<NcCheckboxRadioSwitch v-bind="selectAllBind" data-cy-files-list-selection-checkbox @update:checked="onToggleAll" />
</th>
<!-- Columns display -->

View file

@ -3,7 +3,7 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="files-list__column files-list__row-actions-batch">
<div class="files-list__column files-list__row-actions-batch" data-cy-files-list-selection-actions>
<NcActions ref="actionsMenu"
container="#app-content-vue"
:disabled="!!loading || areSomeNodesLoading"
@ -15,6 +15,7 @@
:key="action.id"
:aria-label="action.displayName(nodes, currentView) + ' ' + t('files', '(selected)') /** TRANSLATORS: Selected like 'selected files and folders' */"
:class="'files-list__row-actions-batch-' + action.id"
:data-cy-files-list-selection-action="action.id"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />

View file

@ -0,0 +1,500 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog data-cy-systemtags-picker
:name="t('systemtags', 'Manage tags')"
:open="opened"
:class="'systemtags-picker--' + status"
class="systemtags-picker"
close-on-click-outside
out-transition
@update:open="onCancel">
<NcEmptyContent v-if="status === Status.LOADING || status === Status.DONE"
:name="t('systemtags', 'Applying tags changes…')">
<template #icon>
<NcLoadingIcon v-if="status === Status.LOADING" />
<CheckIcon v-else fill-color="var(--color-success)" />
</template>
</NcEmptyContent>
<template v-else>
<!-- Search or create input -->
<form class="systemtags-picker__create" @submit.stop.prevent="onNewTag">
<NcTextField :value.sync="input"
:label="t('systemtags', 'Search or create tag')"
data-cy-systemtags-picker-input>
<TagIcon :size="20" />
</NcTextField>
<NcButton :disabled="status === Status.CREATING_TAG"
native-type="submit"
data-cy-systemtags-picker-input-submit>
{{ t('systemtags', 'Create tag') }}
</NcButton>
</form>
<!-- Tags list -->
<div v-if="filteredTags.length > 0"
class="systemtags-picker__tags"
data-cy-systemtags-picker-tags>
<NcCheckboxRadioSwitch v-for="tag in filteredTags"
:key="tag.id"
:label="tag.displayName"
:checked="isChecked(tag)"
:indeterminate="isIndeterminate(tag)"
:disabled="!tag.canAssign"
:data-cy-systemtags-picker-tag="tag.id"
@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 :disabled="status !== Status.BASE"
type="tertiary"
data-cy-systemtags-picker-button-cancel
@click="onCancel">
{{ t('systemtags', 'Cancel') }}
</NcButton>
<NcButton :disabled="!hasChanges || status !== Status.BASE"
data-cy-systemtags-picker-button-submit
@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>
<script lang="ts">
import type { Node } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { Tag, TagWithId } from '../types'
import { defineComponent } from 'vue'
import { emit } from '@nextcloud/event-bus'
import { sanitize } from 'dompurify'
import { showError, showInfo } from '@nextcloud/dialogs'
import { getLanguage, n, 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 { getNodeSystemTags, setNodeSystemTags } from '../utils'
import { createTag, fetchTag, fetchTags, getTagObjects, setTagObjects } from '../services/api'
import logger from '../services/logger'
type TagListCount = {
string: number
}
enum Status {
BASE = 'base',
LOADING = 'loading',
CREATING_TAG = 'creating-tag',
DONE = 'done',
}
export default defineComponent({
name: 'SystemTagPicker',
components: {
CheckIcon,
NcButton,
NcCheckboxRadioSwitch,
// eslint-disable-next-line vue/no-unused-components
NcChip,
NcDialog,
NcEmptyContent,
NcLoadingIcon,
NcNoteCard,
NcTextField,
TagIcon,
},
props: {
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
},
setup() {
return {
emit,
Status,
t,
}
},
data() {
return {
status: Status.BASE,
opened: true,
input: '',
tags: [] as TagWithId[],
tagList: {} as TagListCount,
toAdd: [] as TagWithId[],
toRemove: [] as TagWithId[],
}
},
computed: {
filteredTags(): TagWithId[] {
if (this.input.trim() === '') {
return this.tags
}
return this.tags
.filter(tag => tag.displayName.normalize().includes(this.input.normalize()))
},
hasChanges(): boolean {
return this.toAdd.length > 0 || this.toRemove.length > 0
},
statusMessage(): string {
if (this.toAdd.length === 0 && this.toRemove.length === 0) {
// should not happen
return ''
}
if (this.toAdd.length === 1 && this.toRemove.length === 1) {
return n(
'systemtags',
'{tag1} will be set and {tag2} will be removed from 1 file.',
'{tag1} and {tag2} will be set and removed from {count} files.',
this.nodes.length,
{
tag1: this.formatTagChip(this.toAdd[0]),
tag2: this.formatTagChip(this.toRemove[0]),
count: this.nodes.length,
},
{ escape: false },
)
}
const tagsAdd = this.toAdd.map(this.formatTagChip)
const lastTagAdd = tagsAdd.pop() as string
const tagsRemove = this.toRemove.map(this.formatTagChip)
const lastTagRemove = tagsRemove.pop() as string
const addStringSingular = n(
'systemtags',
'{tag} will be set to 1 file.',
'{tag} will be set to {count} files.',
this.nodes.length,
{
tag: lastTagAdd,
count: this.nodes.length,
},
{ escape: false },
)
const removeStringSingular = n(
'systemtags',
'{tag} will be removed from 1 file.',
'{tag} will be removed from {count} files.',
this.nodes.length,
{
tag: lastTagRemove,
count: this.nodes.length,
},
{ escape: false },
)
const addStringPlural = n(
'systemtags',
'{tags} and {lastTag} will be set to 1 file.',
'{tags} and {lastTag} will be set to {count} files.',
this.nodes.length,
{
tags: tagsAdd.join(', '),
lastTag: lastTagAdd,
count: this.nodes.length,
},
{ escape: false },
)
const removeStringPlural = n(
'systemtags',
'{tags} and {lastTag} will be removed from 1 file.',
'{tags} and {lastTag} will be removed from {count} files.',
this.nodes.length,
{
tags: tagsRemove.join(', '),
lastTag: lastTagRemove,
count: this.nodes.length,
},
{ escape: false },
)
// Singular
if (this.toAdd.length === 1 && this.toRemove.length === 0) {
return addStringSingular
}
if (this.toAdd.length === 0 && this.toRemove.length === 1) {
return removeStringSingular
}
// Plural
if (this.toAdd.length > 1 && this.toRemove.length === 0) {
return addStringPlural
}
if (this.toAdd.length === 0 && this.toRemove.length > 1) {
return removeStringPlural
}
// Mixed
if (this.toAdd.length > 1 && this.toRemove.length === 1) {
return `${addStringPlural} ${removeStringSingular}`
}
if (this.toAdd.length === 1 && this.toRemove.length > 1) {
return `${addStringSingular} ${removeStringPlural}`
}
// Both plural
return `${addStringPlural} ${removeStringPlural}`
},
},
beforeMount() {
fetchTags().then(tags => {
this.tags = tags
})
// Efficient way of counting tags and their occurrences
this.tagList = this.nodes.reduce((acc: TagListCount, node: Node) => {
const tags = getNodeSystemTags(node) || []
tags.forEach(tag => {
acc[tag] = (acc[tag] || 0) + 1
})
return acc
}, {} as TagListCount) as TagListCount
},
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 })
}
if (!tag.userAssignable) {
return t('systemtags', '{displayName} (restricted)', { displayName: tag.displayName })
}
return tag.displayName
},
isChecked(tag: TagWithId): boolean {
return tag.displayName in this.tagList
&& this.tagList[tag.displayName] === this.nodes.length
},
isIndeterminate(tag: TagWithId): boolean {
return tag.displayName in this.tagList
&& this.tagList[tag.displayName] !== 0
&& this.tagList[tag.displayName] !== this.nodes.length
},
onCheckUpdate(tag: TagWithId, checked: boolean) {
if (checked) {
this.toAdd.push(tag)
this.toRemove = this.toRemove.filter(search => search.id !== tag.id)
this.tagList[tag.displayName] = this.nodes.length
} else {
this.toRemove.push(tag)
this.toAdd = this.toAdd.filter(search => search.id !== tag.id)
this.tagList[tag.displayName] = 0
}
},
async onNewTag() {
this.status = Status.CREATING_TAG
try {
const payload: Tag = {
displayName: this.input.trim(),
userAssignable: true,
userVisible: true,
canAssign: true,
}
const id = await createTag(payload)
const tag = await fetchTag(id)
this.tags.push(tag)
this.input = ''
// Check the newly created tag
this.onCheckUpdate(tag, true)
} catch (error) {
showError((error as Error)?.message || t('systemtags', 'Failed to create tag'))
} finally {
this.status = Status.BASE
}
},
async onSubmit() {
this.status = Status.LOADING
logger.debug('Applying tags', {
toAdd: this.toAdd,
toRemove: this.toRemove,
})
try {
// Add tags
for (const tag of this.toAdd) {
const { etag, objects } = await getTagObjects(tag, 'files')
// Create a new list of ids in one pass
const ids = [...new Set([
...objects.map(obj => obj.id).filter(Boolean),
...this.nodes.map(node => node.fileid).filter(Boolean),
])] as number[]
// Set tags
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')
// Get file IDs from the nodes array just once
const nodeFileIds = new Set(this.nodes.map(node => node.fileid))
// Create a filtered and deduplicated list of ids in one pass
const ids = objects
.map(obj => obj.id)
.filter((id, index, self) => !nodeFileIds.has(id) && self.indexOf(id) === index)
// Set tags
await setTagObjects(tag, 'files', ids.map(id => ({ id, type: 'files' })), etag)
}
} catch (error) {
logger.error('Failed to apply tags', { error })
showError(t('systemtags', 'Failed to apply tags changes'))
this.status = Status.BASE
return
}
const nodes = [] as Node[]
// Update nodes
this.toAdd.forEach(tag => {
this.nodes.forEach(node => {
const tags = [...(getNodeSystemTags(node) || []), tag.displayName]
.sort((a, b) => a.localeCompare(b, getLanguage(), { ignorePunctuation: true }))
setNodeSystemTags(node, tags)
nodes.push(node)
})
})
this.toRemove.forEach(tag => {
this.nodes.forEach(node => {
const tags = [...(getNodeSystemTags(node) || [])].filter(t => t !== tag.displayName)
.sort((a, b) => a.localeCompare(b, getLanguage(), { ignorePunctuation: true }))
setNodeSystemTags(node, tags)
nodes.push(node)
})
})
// trigger update event
nodes.forEach(node => emit('systemtags:node:updated', node))
this.status = Status.DONE
setTimeout(() => {
this.opened = false
this.$emit('close', true)
}, 2000)
},
onCancel() {
this.opened = false
showInfo(t('systemtags', 'File tags modification canceled'))
this.$emit('close', null)
},
},
})
</script>
<style scoped lang="scss">
// Common sticky properties
.systemtags-picker__create,
.systemtags-picker__note {
position: sticky;
z-index: 9;
background-color: var(--color-main-background);
}
.systemtags-picker__create {
display: flex;
top: 0;
gap: 8px;
padding-block-end: 8px;
align-items: flex-end;
button {
flex-shrink: 0;
}
}
.systemtags-picker__note {
bottom: 0;
padding-block: 8px;
& > div {
margin: 0 !important;
}
}
.systemtags-picker--done :deep(.empty-content__icon) {
opacity: 1;
}
// Rendered chip in note
.nc-chip {
display: inline !important;
}
</style>

13
apps/systemtags/src/event-bus.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Node } from '@nextcloud/files'
declare module '@nextcloud/event-bus' {
interface NextcloudEvents {
'systemtags:node:updated': Node
}
}
export {}

View file

@ -0,0 +1,50 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { type Node } from '@nextcloud/files'
import { defineAsyncComponent } from 'vue'
import { FileAction } from '@nextcloud/files'
import { isPublicShare } from '@nextcloud/sharing/public'
import { spawnDialog } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import TagMultipleSvg from '@mdi/svg/svg/tag-multiple.svg?raw'
async function execBatch(nodes: Node[]): Promise<(null|boolean)[]> {
const response = await new Promise<null|boolean>((resolve) => {
spawnDialog(defineAsyncComponent(() => import('../components/SystemTagPicker.vue')), {
nodes,
}, (status) => {
resolve(status as null|boolean)
})
})
return Array(nodes.length).fill(response)
}
export const action = new FileAction({
id: 'systemtags:bulk',
displayName: () => t('systemtags', 'Manage tags'),
iconSvgInline: () => TagMultipleSvg,
// If the app is disabled, the action is not available anyway
enabled(nodes) {
if (isPublicShare()) {
return false
}
if (nodes.length === 0) {
return false
}
// If the user is not logged in, the action is not available
return true
},
async exec(node: Node) {
return execBatch([node])[0]
},
execBatch,
})

View file

@ -5,6 +5,8 @@
import { action } from './inlineSystemTagsAction'
import { describe, expect, test } from 'vitest'
import { File, Permission, View, FileAction } from '@nextcloud/files'
import { emit, subscribe } from '@nextcloud/event-bus'
import { setNodeSystemTags } from '../utils'
const view = {
id: 'files',
@ -28,7 +30,8 @@ describe('Inline system tags action conditions tests', () => {
expect(action.default).toBeUndefined()
expect(action.enabled).toBeDefined()
expect(action.order).toBe(0)
expect(action.enabled!([file], view)).toBe(false)
// Always enabled
expect(action.enabled!([file], view)).toBe(true)
})
test('Enabled with valid system tags', () => {
@ -50,7 +53,7 @@ describe('Inline system tags action conditions tests', () => {
})
describe('Inline system tags action render tests', () => {
test('Render nothing when Node does not have system tags', async () => {
test('Render something even when Node does not have system tags', async () => {
const file = new File({
id: 1,
source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
@ -60,7 +63,10 @@ describe('Inline system tags action render tests', () => {
})
const result = await action.renderInline!(file, view)
expect(result).toBeNull()
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot(
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"></ul>"',
)
})
test('Render a single system tag', async () => {
@ -80,7 +86,7 @@ describe('Inline system tags action render tests', () => {
const result = await action.renderInline!(file, view)
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot(
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags"><li class="files-list__system-tag">Confidential</li></ul>"',
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Confidential</li></ul>"',
)
})
@ -101,7 +107,7 @@ describe('Inline system tags action render tests', () => {
const result = await action.renderInline!(file, view)
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot(
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag">Confidential</li></ul>"',
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag">Confidential</li></ul>"',
)
})
@ -127,7 +133,51 @@ describe('Inline system tags action render tests', () => {
const result = await action.renderInline!(file, view)
expect(result).toBeInstanceOf(HTMLElement)
expect(result!.outerHTML).toMatchInlineSnapshot(
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag files-list__system-tag--more" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually">Confidential</li><li class="files-list__system-tag hidden-visually">Secret</li><li class="files-list__system-tag hidden-visually">Classified</li></ul>"',
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag files-list__system-tag--more" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually">Confidential</li><li class="files-list__system-tag hidden-visually">Secret</li><li class="files-list__system-tag hidden-visually">Classified</li></ul>"',
)
})
test('Render gets updated on system tag change', async () => {
const file = new File({
id: 1,
source: 'http://localhost/remote.php/dav/files/admin/foobar.txt',
owner: 'admin',
mime: 'text/plain',
permissions: Permission.ALL,
attributes: {
'system-tags': {
'system-tag': [
'Important',
'Confidential',
'Secret',
'Classified',
],
},
},
})
const result = await action.renderInline!(file, view) as HTMLElement
document.body.appendChild(result)
expect(result).toBeInstanceOf(HTMLElement)
expect(document.body.innerHTML).toMatchInlineSnapshot(
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Important</li><li class="files-list__system-tag files-list__system-tag--more" title="Confidential, Secret, Classified" aria-hidden="true" role="presentation">+3</li><li class="files-list__system-tag hidden-visually">Confidential</li><li class="files-list__system-tag hidden-visually">Secret</li><li class="files-list__system-tag hidden-visually">Classified</li></ul>"',
)
// Subscribe to the event
const eventPromise = new Promise((resolve) => {
subscribe('systemtags:node:updated', resolve)
})
// Change tags
setNodeSystemTags(file, ['Public'])
emit('systemtags:node:updated', file)
expect(file.attributes!['system-tags']!['system-tag']).toEqual(['Public'])
// Wait for the event to be processed
await eventPromise
expect(document.body.innerHTML).toMatchInlineSnapshot(
'"<ul class="files-list__system-tags" aria-label="Assigned collaborative tags" data-systemtags-fileid="1"><li class="files-list__system-tag">Public</li></ul>"',
)
})
})

View file

@ -4,19 +4,11 @@
*/
import type { Node } from '@nextcloud/files'
import { FileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import { subscribe } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import '../css/fileEntryInlineSystemTags.scss'
const getNodeSystemTags = function(node: Node): string[] {
const tags = node.attributes?.['system-tags']?.['system-tag'] as string|string[]|undefined
if (tags === undefined) {
return []
}
return [tags].flat()
}
import { getNodeSystemTags } from '../utils'
const renderTag = function(tag: string, isMore = false): HTMLElement {
const tagElement = document.createElement('li')
@ -30,6 +22,46 @@ const renderTag = function(tag: string, isMore = false): HTMLElement {
return tagElement
}
const renderInline = async function(node: Node): Promise<HTMLElement> {
// Ensure we have the system tags as an array
const tags = getNodeSystemTags(node)
const systemTagsElement = document.createElement('ul')
systemTagsElement.classList.add('files-list__system-tags')
systemTagsElement.setAttribute('aria-label', t('files', 'Assigned collaborative tags'))
systemTagsElement.setAttribute('data-systemtags-fileid', node.fileid?.toString() || '')
if (tags.length === 0) {
return systemTagsElement
}
systemTagsElement.append(renderTag(tags[0]))
if (tags.length === 2) {
// Special case only two tags:
// the overflow fake tag would take the same space as this, so render it
systemTagsElement.append(renderTag(tags[1]))
} else if (tags.length > 1) {
// More tags than the one we're showing
// So we add a overflow element indicating there are more tags
const moreTagElement = renderTag('+' + (tags.length - 1), true)
moreTagElement.setAttribute('title', tags.slice(1).join(', '))
// because the title is not accessible we hide this element for screen readers (see alternative below)
moreTagElement.setAttribute('aria-hidden', 'true')
moreTagElement.setAttribute('role', 'presentation')
systemTagsElement.append(moreTagElement)
// For accessibility the tags are listed, as the title is not accessible
// but those tags are visually hidden
for (const tag of tags.slice(1)) {
const tagElement = renderTag(tag)
tagElement.classList.add('hidden-visually')
systemTagsElement.append(tagElement)
}
}
return systemTagsElement
}
export const action = new FileAction({
id: 'system-tags',
displayName: () => '',
@ -41,57 +73,23 @@ export const action = new FileAction({
return false
}
const node = nodes[0]
const tags = getNodeSystemTags(node)
// Only show the action if the node has system tags
if (tags.length === 0) {
return false
}
// Always show the action, even if there are no tags
// This will render an empty tag list and allow events to update it
return true
},
exec: async () => null,
async renderInline(node: Node) {
// Ensure we have the system tags as an array
const tags = getNodeSystemTags(node)
if (tags.length === 0) {
return null
}
const systemTagsElement = document.createElement('ul')
systemTagsElement.classList.add('files-list__system-tags')
systemTagsElement.setAttribute('aria-label', t('files', 'Assigned collaborative tags'))
systemTagsElement.append(renderTag(tags[0]))
if (tags.length === 2) {
// Special case only two tags:
// the overflow fake tag would take the same space as this, so render it
systemTagsElement.append(renderTag(tags[1]))
} else if (tags.length > 1) {
// More tags than the one we're showing
// So we add a overflow element indicating there are more tags
const moreTagElement = renderTag('+' + (tags.length - 1), true)
moreTagElement.setAttribute('title', tags.slice(1).join(', '))
// because the title is not accessible we hide this element for screen readers (see alternative below)
moreTagElement.setAttribute('aria-hidden', 'true')
moreTagElement.setAttribute('role', 'presentation')
systemTagsElement.append(moreTagElement)
// For accessibility the tags are listed, as the title is not accessible
// but those tags are visually hidden
for (const tag of tags.slice(1)) {
const tagElement = renderTag(tag)
tagElement.classList.add('hidden-visually')
systemTagsElement.append(tagElement)
}
}
return systemTagsElement
},
renderInline,
order: 0,
})
const updateSystemTagsHtml = function(node: Node) {
renderInline(node).then((systemTagsHtml) => {
document.querySelectorAll(`[data-systemtags-fileid="${node.fileid}"]`).forEach((element) => {
element.replaceWith(systemTagsHtml)
})
})
}
subscribe('systemtags:node:updated', updateSystemTagsHtml)

View file

@ -2,8 +2,12 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { translate as t } from '@nextcloud/l10n'
import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files'
import { type Node } from '@nextcloud/files'
import { FileType, FileAction, DefaultType } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { systemTagsViewId } from '../files_views/systemtagsView'
export const action = new FileAction({
id: 'systemtags:open-in-files',
@ -12,7 +16,7 @@ export const action = new FileAction({
enabled(nodes, view) {
// Only for the system tags view
if (view.id !== 'tags') {
if (view.id !== systemTagsViewId) {
return false
}
// Only for single nodes

View file

@ -9,13 +9,15 @@ import { getContents } from '../services/systemtags.js'
import svgTagMultiple from '@mdi/svg/svg/tag-multiple.svg?raw'
export const systemTagsViewId = 'tags'
/**
* Register the system tags files view
*/
export function registerSystemTagsView() {
const Navigation = getNavigation()
Navigation.register(new View({
id: 'tags',
id: systemTagsViewId,
name: t('systemtags', 'Tags'),
caption: t('systemtags', 'List of tags and their associated files and folders.'),

View file

@ -3,11 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { registerDavProperty, registerFileAction } from '@nextcloud/files'
import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction.js'
import { action as openInFilesAction } from './files_actions/openInFilesAction.js'
import { registerSystemTagsView } from './files_views/systemtagsView.js'
import { action as bulkSystemTagsAction } from './files_actions/bulkSystemTagsAction'
import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction'
import { action as openInFilesAction } from './files_actions/openInFilesAction'
import { registerSystemTagsView } from './files_views/systemtagsView'
registerDavProperty('nc:system-tags')
registerFileAction(bulkSystemTagsAction)
registerFileAction(inlineSystemTagsAction)
registerFileAction(openInFilesAction)

View file

@ -3,25 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { FileStat, ResponseDataDetailed, WebDAVClientError } from 'webdav'
import type { ServerTag, Tag, TagWithId } from '../types.js'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import { t } from '@nextcloud/l10n'
import { davClient } from './davClient.js'
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 />
<oc:user-visible />
<oc:user-assignable />
<oc:can-assign />
<d:getetag />
</d:prop>
</d:propfind>`
@ -40,6 +41,20 @@ export const fetchTags = async (): Promise<TagWithId[]> => {
}
}
export const fetchTag = async (tagId: number): Promise<TagWithId> => {
const path = '/systemtags/' + tagId
try {
const { data: tag } = await davClient.stat(path, {
data: fetchTagsPayload,
details: true
}) as ResponseDataDetailed<Required<FileStat>>
return parseTags([tag])[0]
} catch (error) {
logger.error(t('systemtags', 'Failed to load tag'), { error })
throw new Error(t('systemtags', 'Failed to load tag'))
}
}
export const fetchLastUsedTagIds = async (): Promise<number[]> => {
const url = generateUrl('/apps/systemtags/lastused')
try {
@ -71,6 +86,10 @@ export const createTag = async (tag: Tag | ServerTag): Promise<number> => {
logger.error(t('systemtags', 'Missing "Content-Location" header'))
throw new Error(t('systemtags', 'Missing "Content-Location" header'))
} catch (error) {
if ((error as WebDAVClientError)?.response?.status === 409) {
logger.error(t('systemtags', 'A tag with the same name already exists'), { error })
throw new Error(t('systemtags', 'A tag with the same name already exists'))
}
logger.error(t('systemtags', 'Failed to create tag'), { error })
throw new Error(t('systemtags', 'Failed to create tag'))
}
@ -79,7 +98,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 +128,72 @@ 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.
* @param tag The tag to set the objects for
* @param type The type of the objects
* @param objectIds The objects to set
* @param etag Strongly recommended to avoid conflict and data loss.
*/
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,
},
})
}

View file

@ -0,0 +1,10 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
export default getLoggerBuilder()
.setApp('systemtags')
.detectUser()
.build()

View file

@ -8,6 +8,8 @@ import camelCase from 'camelcase'
import type { DAVResultResponseProps } from 'webdav'
import type { BaseTag, ServerTag, Tag, TagWithId } from './types.js'
import type { Node } from '@nextcloud/files'
import Vue from 'vue'
export const defaultBaseTag: BaseTag = {
userVisible: true,
@ -55,3 +57,19 @@ export const formatTag = (initialTag: Tag | ServerTag): ServerTag => {
return tag as unknown as ServerTag
}
export const getNodeSystemTags = function(node: Node): string[] {
const tags = node.attributes?.['system-tags']?.['system-tag'] as string|string[]|undefined
if (tags === undefined) {
return []
}
return [tags].flat()
}
export const setNodeSystemTags = function(node: Node, tags: string[]): void {
Vue.set(node.attributes, 'system-tags', {
'system-tag': tags,
})
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Migrations;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Add objecttype index to systemtag_object_mapping
*/
class Version31000Date20241018063111 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if ($schema->hasTable('systemtag_object_mapping')) {
$table = $schema->getTable('systemtag_object_mapping');
if (!$table->hasIndex('systag_objecttype')) {
$table->addIndex(['objecttype'], 'systag_objecttype');
}
}
if ($schema->hasTable('systemtag')) {
$table = $schema->getTable('systemtag');
if (!$table->hasColumn('etag')) {
$table->addColumn('etag', 'string', [
'notnull' => false,
'length' => 32,
]);
}
}
return $schema;
}
}

View file

@ -14,11 +14,15 @@ export const getActionButtonForFile = (filename: string) => getActionsForFile(fi
export const triggerActionForFileId = (fileid: number, actionId: string) => {
getActionButtonForFileId(fileid).click()
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
// Getting the last button to avoid the one from popup fading out
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
.should('exist').click()
}
export const triggerActionForFile = (filename: string, actionId: string) => {
getActionButtonForFile(filename).click()
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).should('exist').click()
// Getting the last button to avoid the one from popup fading out
cy.get(`[data-cy-files-list-row-action="${CSS.escape(actionId)}"] > button`).last()
.should('exist').click()
}
export const triggerInlineActionForFileId = (fileid: number, actionId: string) => {
@ -28,6 +32,25 @@ export const triggerInlineActionForFile = (filename: string, actionId: string) =
getActionsForFile(filename).get(`button[data-cy-files-list-row-action="${CSS.escape(actionId)}"]`).should('exist').click()
}
export const selectAllFiles = () => {
cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').click({ force: true })
}
export const selectRowForFile = (filename: string) => {
getRowForFile(filename)
.find('[data-cy-files-list-row-checkbox]')
.findByRole('checkbox')
.click({ force: true })
.should('be.checked')
cy.get('[data-cy-files-list-selection-checkbox]').findByRole('checkbox').should('satisfy', (elements) => {
return elements.length === 1 && (elements[0].checked === true || elements[0].indeterminate === true)
})
}
export const triggerSelectionAction = (actionId: string) => {
cy.get(`button[data-cy-files-list-selection-action="${CSS.escape(actionId)}"]`).should('exist').click()
}
export const moveFile = (fileName: string, dirPath: string) => {
getRowForFile(fileName).should('be.visible')
triggerActionForFile(fileName, 'move-copy')

View file

@ -2,7 +2,7 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getActionsForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts'
import { getActionButtonForFile, getRowForFile, navigateToFolder } from '../../files/FilesUtils.ts'
import { openSharingPanel } from '../FilesSharingUtils.ts'
describe('files_sharing: Public share - View only', { testIsolation: true }, () => {
@ -85,7 +85,7 @@ describe('files_sharing: Public share - View only', { testIsolation: true }, ()
})
it('Only download action is actions available', () => {
getActionsForFile('foo.txt')
getActionButtonForFile('foo.txt')
.should('be.visible')
.click()

View file

@ -0,0 +1,354 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/cypress'
import { randomBytes } from 'crypto'
import { getRowForFile, selectAllFiles, selectRowForFile, triggerSelectionAction } from '../files/FilesUtils'
import { createShare } from '../files_sharing/FilesSharingUtils'
let tags = {} as Record<string, number>
const files = [
'file1.txt',
'file2.txt',
'file3.txt',
'file4.txt',
'file5.txt',
]
function resetTags() {
tags = {}
for (const tag in [0, 1, 2, 3, 4]) {
tags[randomBytes(8).toString('base64').slice(0, 6)] = 0
}
// delete any existing tags
cy.runOccCommand('tag:list --output=json').then((output) => {
Object.keys(JSON.parse(output.stdout)).forEach((id) => {
cy.runOccCommand(`tag:delete ${id}`)
})
})
// create tags
Object.keys(tags).forEach((tag) => {
cy.runOccCommand(`tag:add ${tag} public --output=json`).then((output) => {
tags[tag] = JSON.parse(output.stdout).id as number
})
})
cy.log('Using tags', tags)
}
function expectInlineTagForFile(file: string, tags: string[]) {
getRowForFile(file)
.find('[data-systemtags-fileid]')
.findAllByRole('listitem')
.should('have.length', tags.length)
.each(tag => {
expect(tag.text()).to.be.oneOf(tags)
})
}
function triggerTagManagementDialogAction() {
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/').as('getTagsList')
triggerSelectionAction('systemtags:bulk')
cy.wait('@getTagsList')
cy.get('[data-cy-systemtags-picker]').should('be.visible')
}
describe('Systemtags: Files bulk action', { testIsolation: false }, () => {
let snapshot: string
let user1: User
let user2: User
before(() => {
cy.createRandomUser().then((_user1) => {
user1 = _user1
cy.createRandomUser().then((_user2) => {
user2 = _user2
})
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
})
resetTags()
})
it('Can assign tag to selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectRowForFile('file2.txt')
selectRowForFile('file4.txt')
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const tag = Object.keys(tags)[3]
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file2.txt', [tag])
expectInlineTagForFile('file4.txt', [tag])
})
it('Can assign multiple tags to selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const prevTag = Object.keys(tags)[3]
const tag1 = Object.keys(tags)[1]
const tag2 = Object.keys(tags)[2]
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('@getTagData.all').should('have.length', 2)
cy.get('@assignTagData.all').should('have.length', 2)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [tag1, tag2])
expectInlineTagForFile('file2.txt', [prevTag, tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1, tag2])
expectInlineTagForFile('file4.txt', [prevTag, tag1, tag2])
expectInlineTagForFile('file5.txt', [tag1, tag2])
})
it('Can remove tag from selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectRowForFile('file1.txt')
selectRowForFile('file3.txt')
selectRowForFile('file4.txt')
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const firstTag = Object.keys(tags)[3]
const tag1 = Object.keys(tags)[1]
const tag2 = Object.keys(tags)[2]
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [tag1])
expectInlineTagForFile('file2.txt', [firstTag, tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1])
expectInlineTagForFile('file4.txt', [firstTag, tag1])
expectInlineTagForFile('file5.txt', [tag1, tag2])
})
it('Can remove multiple tags from selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
cy.get('[data-cy-systemtags-picker-tag] input:indeterminate').should('exist')
.click({ force: true, multiple: true })
// indeterminate became checked
cy.get('[data-cy-systemtags-picker-tag] input:checked').should('exist')
.click({ force: true, multiple: true })
// now all are unchecked
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('@getTagData.all').should('have.length', 3)
cy.get('@assignTagData.all').should('have.length', 3)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [])
expectInlineTagForFile('file2.txt', [])
expectInlineTagForFile('file3.txt', [])
expectInlineTagForFile('file4.txt', [])
expectInlineTagForFile('file5.txt', [])
})
it('Can assign and remove multiple tags as a secondary user', () => {
// Create new users
cy.createRandomUser().then((_user1) => {
user1 = _user1
cy.createRandomUser().then((_user2) => {
user2 = _user2
})
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
})
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData1')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData1')
const tag1 = Object.keys(tags)[0]
const tag2 = Object.keys(tags)[3]
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData1')
cy.wait('@assignTagData1')
cy.get('@getTagData1.all').should('have.length', 2)
cy.get('@assignTagData1.all').should('have.length', 2)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [tag1, tag2])
expectInlineTagForFile('file2.txt', [tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1, tag2])
expectInlineTagForFile('file4.txt', [tag1, tag2])
expectInlineTagForFile('file5.txt', [tag1, tag2])
createShare('file1.txt', user2.userId)
createShare('file3.txt', user2.userId)
cy.login(user2)
cy.visit('/apps/files')
getRowForFile('file1.txt').should('be.visible')
getRowForFile('file3.txt').should('be.visible')
expectInlineTagForFile('file1.txt', [tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1, tag2])
selectRowForFile('file1.txt')
selectRowForFile('file3.txt')
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData2')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData2')
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData2')
cy.wait('@assignTagData2')
cy.get('@getTagData2.all').should('have.length', 2)
cy.get('@assignTagData2.all').should('have.length', 2)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [])
expectInlineTagForFile('file3.txt', [])
cy.login(user1)
cy.visit('/apps/files')
expectInlineTagForFile('file1.txt', [])
expectInlineTagForFile('file3.txt', [])
})
it('Can create tag and assign files to it', () => {
cy.createRandomUser().then((user1) => {
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const newTag = randomBytes(8).toString('base64').slice(0, 6)
cy.get('[data-cy-systemtags-picker-input]').type(newTag)
cy.get('[data-cy-systemtags-picker-input-submit]').click()
cy.wait('@createTag')
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 6)
// Verify the new tag is selected by default
cy.get('[data-cy-systemtags-picker-tag]').contains(newTag)
.parents('[data-cy-systemtags-picker-tag]')
.findByRole('checkbox', { hidden: true }).should('be.checked')
// Apply changes
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('@getTagData.all').should('have.length', 1)
cy.get('@assignTagData.all').should('have.length', 1)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [newTag])
expectInlineTagForFile('file2.txt', [newTag])
expectInlineTagForFile('file3.txt', [newTag])
expectInlineTagForFile('file4.txt', [newTag])
expectInlineTagForFile('file5.txt', [newTag])
})
})
})

2
dist/5019-5019.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
5019-5019.js.license

2
dist/5576-5576.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/5576-5576.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/5576-5576.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
5576-5576.js.license

4
dist/802-802.js vendored

File diff suppressed because one or more lines are too long

2
dist/802-802.js.map vendored

File diff suppressed because one or more lines are too long

2
dist/8699-8699.js vendored Normal file

File diff suppressed because one or more lines are too long

342
dist/8699-8699.js.license vendored Normal file
View file

@ -0,0 +1,342 @@
SPDX-License-Identifier: MIT
SPDX-License-Identifier: ISC
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-License-Identifier: BSD-3-Clause
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-License-Identifier: (MPL-2.0 OR Apache-2.0)
SPDX-FileCopyrightText: string_decoder developers
SPDX-FileCopyrightText: readable-stream developers
SPDX-FileCopyrightText: qs developers
SPDX-FileCopyrightText: jden <jason@denizac.org>
SPDX-FileCopyrightText: inherits developers
SPDX-FileCopyrightText: escape-html developers
SPDX-FileCopyrightText: defunctzombie
SPDX-FileCopyrightText: Varun A P
SPDX-FileCopyrightText: Tobias Koppers @sokra
SPDX-FileCopyrightText: T. Jameson Little <t.jameson.little@gmail.com>
SPDX-FileCopyrightText: Sindre Sorhus
SPDX-FileCopyrightText: Roman Shtylman <shtylman@gmail.com>
SPDX-FileCopyrightText: Rob Cresswell <robcresswell@pm.me>
SPDX-FileCopyrightText: Raynos <raynos2@gmail.com>
SPDX-FileCopyrightText: Perry Mitchell <perry@perrymitchell.net>
SPDX-FileCopyrightText: Paul Vorbach <paul@vorba.ch> (http://paul.vorba.ch)
SPDX-FileCopyrightText: Paul Vorbach <paul@vorb.de> (http://vorb.de)
SPDX-FileCopyrightText: Olivier Scherrer <pode.fr@gmail.com>
SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
SPDX-FileCopyrightText: Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)
SPDX-FileCopyrightText: Matt Zabriskie
SPDX-FileCopyrightText: Mathias Bynens
SPDX-FileCopyrightText: Julian Gruber
SPDX-FileCopyrightText: Joyent
SPDX-FileCopyrightText: José F. Romaniello <jfromaniello@gmail.com> (http://joseoncode.com)
SPDX-FileCopyrightText: Jordan Harband <ljharb@gmail.com>
SPDX-FileCopyrightText: Jordan Harband
SPDX-FileCopyrightText: John-David Dalton <john.david.dalton@gmail.com> (http://allyoucanleet.com/)
SPDX-FileCopyrightText: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
SPDX-FileCopyrightText: John Hiesey
SPDX-FileCopyrightText: James Halliday
SPDX-FileCopyrightText: Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me)
SPDX-FileCopyrightText: Irakli Gozalishvili <rfobic@gmail.com> (http://jeditoolkit.com)
SPDX-FileCopyrightText: Guillaume Chau <guillaume.b.chau@gmail.com>
SPDX-FileCopyrightText: GitHub Inc.
SPDX-FileCopyrightText: Feross Aboukhadijeh
SPDX-FileCopyrightText: Evan You
SPDX-FileCopyrightText: Dylan Piercey <pierceydylan@gmail.com>
SPDX-FileCopyrightText: Dr.-Ing. Mario Heiderich, Cure53 <mario@cure53.de> (https://cure53.de/)
SPDX-FileCopyrightText: David Clark
SPDX-FileCopyrightText: Christoph Wurst
SPDX-FileCopyrightText: Ben Drucker
SPDX-FileCopyrightText: Arnout Kazemier
SPDX-FileCopyrightText: Anthony Fu <https://github.com/antfu>
SPDX-FileCopyrightText: Andris Reinman
SPDX-FileCopyrightText: Amit Gupta (https://solothought.com)
SPDX-FileCopyrightText: Amit Gupta (https://amitkumargupta.work/)
SPDX-FileCopyrightText: @nextcloud/dialogs developers
This file is generated from multiple sources. Included packages:
- @nextcloud/auth
- version: 2.4.0
- license: GPL-3.0-or-later
- @nextcloud/axios
- version: 2.5.1
- license: GPL-3.0-or-later
- @nextcloud/browser-storage
- version: 0.4.0
- license: GPL-3.0-or-later
- @nextcloud/dialogs
- version: 6.0.0
- license: AGPL-3.0-or-later
- semver
- version: 7.6.3
- license: ISC
- @nextcloud/event-bus
- version: 3.3.1
- license: GPL-3.0-or-later
- @nextcloud/l10n
- version: 3.1.0
- license: GPL-3.0-or-later
- @nextcloud/logger
- version: 3.0.2
- license: GPL-3.0-or-later
- @nextcloud/router
- version: 3.0.1
- license: GPL-3.0-or-later
- @nextcloud/vue
- version: 8.17.1
- license: AGPL-3.0-or-later
- @vueuse/core
- version: 11.1.0
- license: MIT
- @vueuse/shared
- version: 11.1.0
- license: MIT
- available-typed-arrays
- version: 1.0.7
- license: MIT
- axios
- version: 1.7.7
- license: MIT
- balanced-match
- version: 1.0.2
- license: MIT
- base-64
- version: 1.0.0
- license: MIT
- base64-js
- version: 1.5.1
- license: MIT
- brace-expansion
- version: 2.0.1
- license: MIT
- buffer
- version: 5.7.1
- license: MIT
- builtin-status-codes
- version: 3.0.0
- license: MIT
- byte-length
- version: 1.0.2
- license: MIT
- call-bind
- version: 1.0.7
- license: MIT
- camelcase
- version: 8.0.0
- license: MIT
- charenc
- version: 0.0.2
- license: BSD-3-Clause
- crypt
- version: 0.0.2
- license: BSD-3-Clause
- css-loader
- version: 7.1.2
- license: MIT
- define-data-property
- version: 1.1.4
- license: MIT
- dompurify
- version: 3.1.7
- license: (MPL-2.0 OR Apache-2.0)
- es-define-property
- version: 1.0.0
- license: MIT
- es-errors
- version: 1.3.0
- license: MIT
- escape-html
- version: 1.0.3
- license: MIT
- events
- version: 3.3.0
- license: MIT
- fast-xml-parser
- version: 4.4.1
- license: MIT
- floating-vue
- version: 1.0.0-beta.19
- license: MIT
- focus-trap
- version: 7.6.0
- license: MIT
- for-each
- version: 0.3.3
- license: MIT
- function-bind
- version: 1.1.2
- license: MIT
- get-intrinsic
- version: 1.2.4
- license: MIT
- gopd
- version: 1.0.1
- license: MIT
- has-property-descriptors
- version: 1.0.2
- license: MIT
- has-proto
- version: 1.0.3
- license: MIT
- has-symbols
- version: 1.0.3
- license: MIT
- has-tostringtag
- version: 1.0.2
- license: MIT
- hasown
- version: 2.0.2
- license: MIT
- hot-patcher
- version: 2.0.1
- license: MIT
- https-browserify
- version: 1.0.0
- license: MIT
- ieee754
- version: 1.2.1
- license: BSD-3-Clause
- inherits
- version: 2.0.4
- license: ISC
- is-arguments
- version: 1.1.1
- license: MIT
- is-buffer
- version: 1.1.6
- license: MIT
- is-callable
- version: 1.2.7
- license: MIT
- is-generator-function
- version: 1.0.10
- license: MIT
- is-typed-array
- version: 1.1.13
- license: MIT
- layerr
- version: 3.0.0
- license: MIT
- lodash.get
- version: 4.4.2
- license: MIT
- md5
- version: 2.3.0
- license: BSD-3-Clause
- minimatch
- version: 9.0.5
- license: ISC
- nested-property
- version: 4.0.0
- license: MIT
- node-gettext
- version: 3.0.0
- license: MIT
- buffer
- version: 6.0.3
- license: MIT
- object-inspect
- version: 1.13.2
- license: MIT
- path-posix
- version: 1.0.0
- license: ISC
- inherits
- version: 2.0.3
- license: ISC
- util
- version: 0.10.4
- license: MIT
- path
- version: 0.12.7
- license: MIT
- possible-typed-array-names
- version: 1.0.0
- license: MIT
- process
- version: 0.11.10
- license: MIT
- punycode
- version: 1.4.1
- license: MIT
- qs
- version: 6.13.0
- license: BSD-3-Clause
- querystringify
- version: 2.2.0
- license: MIT
- requires-port
- version: 1.0.0
- license: MIT
- safe-buffer
- version: 5.2.1
- license: MIT
- set-function-length
- version: 1.2.2
- license: MIT
- side-channel
- version: 1.0.6
- license: MIT
- readable-stream
- version: 3.6.2
- license: MIT
- stream-browserify
- version: 3.0.0
- license: MIT
- readable-stream
- version: 3.6.2
- license: MIT
- stream-http
- version: 3.2.0
- license: MIT
- string_decoder
- version: 1.3.0
- license: MIT
- strnum
- version: 1.0.5
- license: MIT
- style-loader
- version: 4.0.0
- license: MIT
- tabbable
- version: 6.2.0
- license: MIT
- toastify-js
- version: 1.12.0
- license: MIT
- url-join
- version: 5.0.0
- license: MIT
- url-parse
- version: 1.5.10
- license: MIT
- url
- version: 0.11.4
- license: MIT
- util-deprecate
- version: 1.0.2
- license: MIT
- util
- version: 0.12.5
- license: MIT
- vue-loader
- version: 15.11.1
- license: MIT
- vue-material-design-icons
- version: 5.3.0
- license: MIT
- vue
- version: 2.7.16
- license: MIT
- webdav
- version: 5.7.1
- license: MIT
- which-typed-array
- version: 1.1.15
- license: MIT
- xtend
- version: 4.0.2
- license: MIT
- nextcloud
- version: 1.0.0
- license: AGPL-3.0-or-later

1
dist/8699-8699.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/8699-8699.js.map.license vendored Symbolic link
View file

@ -0,0 +1 @@
8699-8699.js.license

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-common.js vendored

File diff suppressed because one or more lines are too long

View file

@ -243,9 +243,6 @@ This file is generated from multiple sources. Included packages:
- call-bind
- version: 1.0.7
- license: MIT
- camelcase
- version: 8.0.0
- license: MIT
- cancelable-promise
- version: 4.3.1
- license: MIT

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-login.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-init.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,2 @@
(()=>{"use strict";var e,r,t,i={97986:(e,r,t)=>{var i=t(61338),o=t(85168),n=t(63814),a=t(53334);const l=(0,t(35947).YK)().setApp("files").detectUser().build();document.addEventListener("DOMContentLoaded",(function(){const e=window.OCA;e.UnifiedSearch&&(l.info("Initializing unified search plugin: folder search from files app"),e.UnifiedSearch.registerFilterAction({id:"files",appId:"files",label:(0,a.Tl)("files","In folder"),icon:(0,n.d0)("files","app.svg"),callback:()=>{(0,o.a1)("Pick plain text files").addMimeTypeFilter("httpd/unix-directory").allowDirectories(!0).addButton({label:"Pick",callback:e=>{l.info("Folder picked",{folder:e[0]});const r=e[0];(0,i.Ic)("nextcloud:unified-search:add-filter",{id:"files",payload:r,filterUpdateText:(0,a.Tl)("files","Search in folder: {folder}",{folder:r.basename}),filterParams:{path:r.path}})}}).build().pick()}}))}))}},o={};function n(e){var r=o[e];if(void 0!==r)return r.exports;var t=o[e]={id:e,loaded:!1,exports:{}};return i[e].call(t.exports,t,t.exports,n),t.loaded=!0,t.exports}n.m=i,e=[],n.O=(r,t,i,o)=>{if(!t){var a=1/0;for(s=0;s<e.length;s++){t=e[s][0],i=e[s][1],o=e[s][2];for(var l=!0,d=0;d<t.length;d++)(!1&o||a>=o)&&Object.keys(n.O).every((e=>n.O[e](t[d])))?t.splice(d--,1):(l=!1,o<a&&(a=o));if(l){e.splice(s--,1);var c=i();void 0!==c&&(r=c)}}return r}o=o||0;for(var s=e.length;s>0&&e[s-1][2]>o;s--)e[s]=e[s-1];e[s]=[t,i,o]},n.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return n.d(r,{a:r}),r},n.d=(e,r)=>{for(var t in r)n.o(r,t)&&!n.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},n.f={},n.e=e=>Promise.all(Object.keys(n.f).reduce(((r,t)=>(n.f[t](e,r),r)),[])),n.u=e=>e+"-"+e+".js?v="+{802:"4fc65efe0f2d990c9ac5",9291:"077955af818a227340aa"}[e],n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),n.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="nextcloud:",n.l=(e,i,o,a)=>{if(r[e])r[e].push(i);else{var l,d;if(void 0!==o)for(var c=document.getElementsByTagName("script"),s=0;s<c.length;s++){var f=c[s];if(f.getAttribute("src")==e||f.getAttribute("data-webpack")==t+o){l=f;break}}l||(d=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,n.nc&&l.setAttribute("nonce",n.nc),l.setAttribute("data-webpack",t+o),l.src=e),r[e]=[i];var u=(t,i)=>{l.onerror=l.onload=null,clearTimeout(p);var o=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),o&&o.forEach((e=>e(i))),t)return t(i)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),d&&document.head.appendChild(l)}},n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),n.j=2277,(()=>{var e;n.g.importScripts&&(e=n.g.location+"");var r=n.g.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var i=t.length-1;i>-1&&(!e||!/^http(s?):/.test(e));)e=t[i--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),n.p=e})(),(()=>{n.b=document.baseURI||self.location.href;var e={2277:0};n.f.j=(r,t)=>{var i=n.o(e,r)?e[r]:void 0;if(0!==i)if(i)t.push(i[2]);else{var o=new Promise(((t,o)=>i=e[r]=[t,o]));t.push(i[2]=o);var a=n.p+n.u(r),l=new Error;n.l(a,(t=>{if(n.o(e,r)&&(0!==(i=e[r])&&(e[r]=void 0),i)){var o=t&&("load"===t.type?"missing":t.type),a=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+o+": "+a+")",l.name="ChunkLoadError",l.type=o,l.request=a,i[1](l)}}),"chunk-"+r,r)}},n.O.j=r=>0===e[r];var r=(r,t)=>{var i,o,a=t[0],l=t[1],d=t[2],c=0;if(a.some((r=>0!==e[r]))){for(i in l)n.o(l,i)&&(n.m[i]=l[i]);if(d)var s=d(n)}for(r&&r(t);c<a.length;c++)o=a[c],n.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return n.O(s)},t=self.webpackChunknextcloud=self.webpackChunknextcloud||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),n.nc=void 0;var a=n.O(void 0,[4208],(()=>n(97986)));a=n.O(a)})();
//# sourceMappingURL=files-search.js.map?v=1c6cee7fe4d34a1fff1a
(()=>{"use strict";var e,r,t,i={97986:(e,r,t)=>{var i=t(61338),o=t(85168),a=t(63814),n=t(53334);const l=(0,t(35947).YK)().setApp("files").detectUser().build();document.addEventListener("DOMContentLoaded",(function(){const e=window.OCA;e.UnifiedSearch&&(l.info("Initializing unified search plugin: folder search from files app"),e.UnifiedSearch.registerFilterAction({id:"files",appId:"files",label:(0,n.Tl)("files","In folder"),icon:(0,a.d0)("files","app.svg"),callback:()=>{(0,o.a1)("Pick plain text files").addMimeTypeFilter("httpd/unix-directory").allowDirectories(!0).addButton({label:"Pick",callback:e=>{l.info("Folder picked",{folder:e[0]});const r=e[0];(0,i.Ic)("nextcloud:unified-search:add-filter",{id:"files",payload:r,filterUpdateText:(0,n.Tl)("files","Search in folder: {folder}",{folder:r.basename}),filterParams:{path:r.path}})}}).build().pick()}}))}))}},o={};function a(e){var r=o[e];if(void 0!==r)return r.exports;var t=o[e]={id:e,loaded:!1,exports:{}};return i[e].call(t.exports,t,t.exports,a),t.loaded=!0,t.exports}a.m=i,e=[],a.O=(r,t,i,o)=>{if(!t){var n=1/0;for(s=0;s<e.length;s++){t=e[s][0],i=e[s][1],o=e[s][2];for(var l=!0,d=0;d<t.length;d++)(!1&o||n>=o)&&Object.keys(a.O).every((e=>a.O[e](t[d])))?t.splice(d--,1):(l=!1,o<n&&(n=o));if(l){e.splice(s--,1);var c=i();void 0!==c&&(r=c)}}return r}o=o||0;for(var s=e.length;s>0&&e[s-1][2]>o;s--)e[s]=e[s-1];e[s]=[t,i,o]},a.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return a.d(r,{a:r}),r},a.d=(e,r)=>{for(var t in r)a.o(r,t)&&!a.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:r[t]})},a.f={},a.e=e=>Promise.all(Object.keys(a.f).reduce(((r,t)=>(a.f[t](e,r),r)),[])),a.u=e=>e+"-"+e+".js?v="+{802:"387187865bf364bcd145",9291:"077955af818a227340aa"}[e],a.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),a.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),r={},t="nextcloud:",a.l=(e,i,o,n)=>{if(r[e])r[e].push(i);else{var l,d;if(void 0!==o)for(var c=document.getElementsByTagName("script"),s=0;s<c.length;s++){var f=c[s];if(f.getAttribute("src")==e||f.getAttribute("data-webpack")==t+o){l=f;break}}l||(d=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,a.nc&&l.setAttribute("nonce",a.nc),l.setAttribute("data-webpack",t+o),l.src=e),r[e]=[i];var u=(t,i)=>{l.onerror=l.onload=null,clearTimeout(p);var o=r[e];if(delete r[e],l.parentNode&&l.parentNode.removeChild(l),o&&o.forEach((e=>e(i))),t)return t(i)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),d&&document.head.appendChild(l)}},a.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),a.j=2277,(()=>{var e;a.g.importScripts&&(e=a.g.location+"");var r=a.g.document;if(!e&&r&&(r.currentScript&&"SCRIPT"===r.currentScript.tagName.toUpperCase()&&(e=r.currentScript.src),!e)){var t=r.getElementsByTagName("script");if(t.length)for(var i=t.length-1;i>-1&&(!e||!/^http(s?):/.test(e));)e=t[i--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),a.p=e})(),(()=>{a.b=document.baseURI||self.location.href;var e={2277:0};a.f.j=(r,t)=>{var i=a.o(e,r)?e[r]:void 0;if(0!==i)if(i)t.push(i[2]);else{var o=new Promise(((t,o)=>i=e[r]=[t,o]));t.push(i[2]=o);var n=a.p+a.u(r),l=new Error;a.l(n,(t=>{if(a.o(e,r)&&(0!==(i=e[r])&&(e[r]=void 0),i)){var o=t&&("load"===t.type?"missing":t.type),n=t&&t.target&&t.target.src;l.message="Loading chunk "+r+" failed.\n("+o+": "+n+")",l.name="ChunkLoadError",l.type=o,l.request=n,i[1](l)}}),"chunk-"+r,r)}},a.O.j=r=>0===e[r];var r=(r,t)=>{var i,o,n=t[0],l=t[1],d=t[2],c=0;if(n.some((r=>0!==e[r]))){for(i in l)a.o(l,i)&&(a.m[i]=l[i]);if(d)var s=d(a)}for(r&&r(t);c<n.length;c++)o=n[c],a.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return a.O(s)},t=self.webpackChunknextcloud=self.webpackChunknextcloud||[];t.forEach(r.bind(null,0)),t.push=r.bind(null,t.push.bind(t))})(),a.nc=void 0;var n=a.O(void 0,[4208],(()=>a(97986)));n=a.O(n)})();
//# sourceMappingURL=files-search.js.map?v=1048554c73d31822a6b6

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -171,9 +171,6 @@ This file is generated from multiple sources. Included packages:
- call-bind
- version: 1.0.7
- license: MIT
- camelcase
- version: 8.0.0
- license: MIT
- cancelable-promise
- version: 4.3.1
- license: MIT

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more