mirror of
https://github.com/nextcloud/server.git
synced 2026-06-11 09:42:09 -04:00
Merge pull request #48786 from nextcloud/feat/files-bulk-tagging
This commit is contained in:
commit
73fdf2c150
143 changed files with 2188 additions and 253 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
apps/dav/lib/SystemTag/SystemTagObjectType.php
Normal file
81
apps/dav/lib/SystemTag/SystemTagObjectType.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
80
apps/dav/lib/SystemTag/SystemTagsObjectList.php
Normal file
80
apps/dav/lib/SystemTag/SystemTagsObjectList.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<NcCheckboxRadioSwitch v-else
|
||||
:aria-label="ariaLabel"
|
||||
:checked="isSelected"
|
||||
data-cy-files-list-row-checkbox
|
||||
@update:checked="onSelectionChange" />
|
||||
</td>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
500
apps/systemtags/src/components/SystemTagPicker.vue
Normal file
500
apps/systemtags/src/components/SystemTagPicker.vue
Normal 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
13
apps/systemtags/src/event-bus.d.ts
vendored
Normal 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 {}
|
||||
50
apps/systemtags/src/files_actions/bulkSystemTagsAction.ts
Normal file
50
apps/systemtags/src/files_actions/bulkSystemTagsAction.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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>"',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
10
apps/systemtags/src/services/logger.ts
Normal file
10
apps/systemtags/src/services/logger.ts
Normal 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()
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
53
core/Migrations/Version31000Date20241018063111.php
Normal file
53
core/Migrations/Version31000Date20241018063111.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
354
cypress/e2e/systemtags/files-bulk-action.cy.ts
Normal file
354
cypress/e2e/systemtags/files-bulk-action.cy.ts
Normal 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
2
dist/5019-5019.js
vendored
File diff suppressed because one or more lines are too long
1
dist/5019-5019.js.map
vendored
1
dist/5019-5019.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/5019-5019.js.map.license
vendored
1
dist/5019-5019.js.map.license
vendored
|
|
@ -1 +0,0 @@
|
|||
5019-5019.js.license
|
||||
2
dist/5576-5576.js
vendored
Normal file
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
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
1
dist/5576-5576.js.map.license
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
5576-5576.js.license
|
||||
4
dist/802-802.js
vendored
4
dist/802-802.js
vendored
File diff suppressed because one or more lines are too long
2
dist/802-802.js.map
vendored
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
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
342
dist/8699-8699.js.license
vendored
Normal 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
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
1
dist/8699-8699.js.map.license
vendored
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
8699-8699.js.license
|
||||
4
dist/comments-comments-app.js
vendored
4
dist/comments-comments-app.js
vendored
File diff suppressed because one or more lines are too long
2
dist/comments-comments-app.js.map
vendored
2
dist/comments-comments-app.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/comments-comments-tab.js
vendored
4
dist/comments-comments-tab.js
vendored
File diff suppressed because one or more lines are too long
2
dist/comments-comments-tab.js.map
vendored
2
dist/comments-comments-tab.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
3
dist/core-common.js.license
vendored
3
dist/core-common.js.license
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-legacy-unified-search.js
vendored
4
dist/core-legacy-unified-search.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-legacy-unified-search.js.map
vendored
2
dist/core-legacy-unified-search.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-login.js
vendored
4
dist/core-login.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-login.js.map
vendored
2
dist/core-login.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-main.js
vendored
4
dist/core-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-main.js.map
vendored
2
dist/core-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-profile.js
vendored
4
dist/core-profile.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-profile.js.map
vendored
2
dist/core-profile.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-public-page-menu.js
vendored
4
dist/core-public-page-menu.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-public-page-menu.js.map
vendored
2
dist/core-public-page-menu.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/dav-settings-personal-availability.js
vendored
4
dist/dav-settings-personal-availability.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
4
dist/files-init.js
vendored
4
dist/files-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-init.js.map
vendored
2
dist/files-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-reference-files.js
vendored
4
dist/files-reference-files.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-reference-files.js.map
vendored
2
dist/files-reference-files.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-search.js
vendored
4
dist/files-search.js
vendored
|
|
@ -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
|
||||
2
dist/files-search.js.map
vendored
2
dist/files-search.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-settings-personal.js
vendored
4
dist/files-settings-personal.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files-settings-personal.js.map
vendored
2
dist/files-settings-personal.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-sidebar.js
vendored
4
dist/files-sidebar.js
vendored
File diff suppressed because one or more lines are too long
3
dist/files-sidebar.js.license
vendored
3
dist/files-sidebar.js.license
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
dist/files-sidebar.js.map
vendored
2
dist/files-sidebar.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_external-init.js
vendored
4
dist/files_external-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_external-init.js.map
vendored
2
dist/files_external-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_reminders-init.js
vendored
4
dist/files_reminders-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_reminders-init.js.map
vendored
2
dist/files_reminders-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-files_sharing_tab.js
vendored
4
dist/files_sharing-files_sharing_tab.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-files_sharing_tab.js.map
vendored
2
dist/files_sharing-files_sharing_tab.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-init.js
vendored
4
dist/files_sharing-init.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-init.js.map
vendored
2
dist/files_sharing-init.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-personal-settings.js
vendored
4
dist/files_sharing-personal-settings.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_sharing-personal-settings.js.map
vendored
2
dist/files_sharing-personal-settings.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files_sharing-public-file-request.js
vendored
4
dist/files_sharing-public-file-request.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_versions-files_versions.js
vendored
4
dist/files_versions-files_versions.js
vendored
File diff suppressed because one or more lines are too long
2
dist/files_versions-files_versions.js.map
vendored
2
dist/files_versions-files_versions.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/settings-declarative-settings-forms.js
vendored
4
dist/settings-declarative-settings-forms.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
4
dist/settings-vue-settings-admin-security.js
vendored
4
dist/settings-vue-settings-admin-security.js
vendored
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
Loading…
Reference in a new issue