Bulk action: add or delete tag to multiple bookmarks (#1898)

This commit is contained in:
ArthurHoaro 2022-11-26 14:58:12 +01:00 committed by GitHub
parent cd618bd8be
commit 00cce1f8c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 543 additions and 0 deletions

View file

@ -517,6 +517,16 @@ public function renameTag(string $fromTag, string $toTag): void
}
}
/**
* Add a tag in tags list.
*
* @param string $tag
*/
public function addTag(string $tag): self
{
return $this->setTags(array_merge($this->getTags(), [$tag]));
}
/**
* Delete a tag from tags list.
*

View file

@ -203,4 +203,85 @@ public function sharePrivate(Request $request, Response $response, array $args):
'/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
);
}
/**
* POST /admin/shaare/update-tags
*
* Bulk add or delete a tags on one or multiple bookmarks.
*/
public function addOrDeleteTags(Request $request, Response $response): Response
{
$this->checkToken($request);
$ids = trim(escape($request->getParam('id') ?? ''));
if (empty($ids) || strpos($ids, ' ') !== false) {
// multiple, space-separated ids provided
$ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
} else {
// only a single id provided
$ids = [$ids];
}
// assert at least one id is given
if (0 === count($ids)) {
$this->saveErrorMessage(t('Invalid bookmark ID provided.'));
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
// assert that the action is valid
$action = $request->getParam('action');
if (!in_array($action, ['add', 'delete'], true)) {
$this->saveErrorMessage(t('Invalid action provided.'));
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
// assert that the tag name is valid
$tagString = trim($request->getParam('tag'));
if (empty($tagString)) {
$this->saveErrorMessage(t('Invalid tag name provided.'));
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
$tags = tags_str2array($tagString, $this->container->conf->get('general.tags_separator', ' '));
$formatter = $this->container->formatterFactory->getFormatter('raw');
$count = 0;
foreach ($ids as $id) {
try {
$bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
$this->saveErrorMessage(sprintf(
t('Bookmark with identifier %s could not be found.'),
$id
));
continue;
}
foreach ($tags as $tag) {
if ($action === 'add') {
$bookmark->addTag($tag);
} else {
$bookmark->deleteTag($tag);
}
}
// To preserve backward compatibility with 3rd parties, plugins still use arrays
$data = $formatter->format($bookmark);
$this->executePageHooks('save_link', $data);
$bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
$this->container->bookmarkService->set($bookmark, false);
++$count;
}
if ($count > 0) {
$this->container->bookmarkService->save();
}
return $this->redirectFromReferer($request, $response, ['/updateTag'], []);
}
}

View file

@ -383,6 +383,10 @@ function init(description) {
});
sub.classList.toggle('open');
const autofocus = sub.querySelector('.autofocus');
if (autofocus) {
autofocus.focus();
}
}
});
});
@ -507,6 +511,37 @@ function init(description) {
});
}
['add', 'delete'].forEach((action) => {
const subHeader = document.getElementById(`bulk-tag-action-${action}`);
if (subHeader) {
subHeader.querySelectorAll('a.button').forEach((link) => {
if (!link.classList.contains('action')) {
return;
}
subHeader.querySelector('input[name="tag"]').addEventListener('keypress', (event) => {
if (event.keyCode === 13) { // enter
link.click();
}
});
link.addEventListener('click', (event) => {
event.preventDefault();
const ids = [];
const linkCheckedCheckboxes = document.querySelectorAll('.link-checkbox:checked');
[...linkCheckedCheckboxes].forEach((checkbox) => {
ids.push(checkbox.value);
});
subHeader.querySelector('input[name="id"]').value = ids.join(' ');
subHeader.querySelector('form').submit();
});
});
}
});
/**
* Select all button
*/

View file

@ -151,6 +151,7 @@
$this->post('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:save');
$this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ShaareManageController:deleteBookmark');
$this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ShaareManageController:changeVisibility');
$this->post('/shaare/update-tags', '\Shaarli\Front\Controller\Admin\ShaareManageController:addOrDeleteSingleTag');
$this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ShaareManageController:pinBookmark');
$this->patch(
'/shaare/{id:[0-9]+}/update-thumbnail',

View file

@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Formatter\BookmarkFormatter;
use Shaarli\Formatter\BookmarkRawFormatter;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
use Shaarli\Front\Controller\Admin\ShaareManageController;
use Shaarli\Http\HttpAccess;
use Shaarli\Security\SessionManager;
use Shaarli\TestCase;
use Slim\Http\Request;
use Slim\Http\Response;
class AddOrDeleteTagTest extends TestCase
{
use FrontAdminControllerMockHelper;
/** @var ShaareManageController */
protected $controller;
public function setUp(): void
{
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
$this->controller = new ShaareManageController($this->container);
}
/**
* Add 1 tag to 1 bookmark
*/
public function testAddOneTagOnOneBookmark(): void
{
$parameters = ['id' => '123', 'tag' => 'newtag', 'action' => 'add'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$bookmark = (new Bookmark())
->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')
->setTagsString('first second');
static::assertSame(['first', 'second'], $bookmark->getTags());
$this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
$this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
$this->container->bookmarkService->expects(static::once())->method('save');
$this->container->formatterFactory = $this->createMock(FormatterFactory::class);
$this->container->formatterFactory
->expects(static::once())
->method('getFormatter')
->with('raw')
->willReturnCallback(function () use ($bookmark): BookmarkFormatter {
return new BookmarkRawFormatter($this->container->conf, true);
})
;
// Make sure that PluginManager hook is triggered
$this->container->pluginManager
->expects(static::once())
->method('executeHooks')
->with('save_link')
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(['first', 'second', 'newtag'], $bookmark->getTags());
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Add 2 tags to 2 bookmarks
*/
public function testAddTwoTagsOnTwoBookmarks(): void
{
$parameters = ['id' => '123 456', 'tag' => 'newtag@othertag', 'action' => 'add'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$bookmark1 = (new Bookmark())
->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')
->setTagsString('first second');
$bookmark2 = (new Bookmark())
->setId(456)->setUrl('http://domain.tld')->setTitle('Title 123');
static::assertSame(['first', 'second'], $bookmark1->getTags());
static::assertSame([], $bookmark2->getTags());
$this->container->bookmarkService->expects(static::exactly(2))->method('get')
->withConsecutive([123], [456])
->willReturnOnConsecutiveCalls($bookmark1, $bookmark2);
$this->container->bookmarkService->expects(static::exactly(2))->method('set')
->withConsecutive([$bookmark1, false], [$bookmark2, false]);
$this->container->bookmarkService->expects(static::once())->method('save');
$this->container->formatterFactory = $this->createMock(FormatterFactory::class);
$this->container->formatterFactory
->expects(static::once())
->method('getFormatter')
->with('raw')
->willReturnCallback(function (): BookmarkFormatter {
return new BookmarkRawFormatter($this->container->conf, true);
})
;
// Make sure that PluginManager hook is triggered
$this->container->pluginManager
->expects(static::exactly(2))
->method('executeHooks')
->with('save_link')
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(['first', 'second', 'newtag', 'othertag'], $bookmark1->getTags());
static::assertSame(['newtag', 'othertag'], $bookmark2->getTags());
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Delete 1 tag to 1 bookmark
*/
public function testDeleteOneTagOnOneBookmark(): void
{
$parameters = ['id' => '123', 'tag' => 'second', 'action' => 'delete'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$bookmark = (new Bookmark())
->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')
->setTagsString('first second third');
static::assertSame(['first', 'second', 'third'], $bookmark->getTags());
$this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
$this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
$this->container->bookmarkService->expects(static::once())->method('save');
$this->container->formatterFactory = $this->createMock(FormatterFactory::class);
$this->container->formatterFactory
->expects(static::once())
->method('getFormatter')
->with('raw')
->willReturnCallback(function () use ($bookmark): BookmarkFormatter {
return new BookmarkRawFormatter($this->container->conf, true);
})
;
// Make sure that PluginManager hook is triggered
$this->container->pluginManager
->expects(static::once())
->method('executeHooks')
->with('save_link')
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(['first', 'third'], $bookmark->getTags());
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Delete 2 tags to 2 bookmarks
*/
public function testDeleteTwoTagOnTwoBookmarks(): void
{
$parameters = ['id' => '123 456', 'tag' => 'second@first', 'action' => 'delete'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$bookmark1 = (new Bookmark())
->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')
->setTagsString('first second third other');
$bookmark2 = (new Bookmark())
->setId(456)->setUrl('http://domain.tld')->setTitle('Title 123')
->setTagsString('first second');
static::assertSame(['first', 'second', 'third', 'other'], $bookmark1->getTags());
static::assertSame(['first', 'second'], $bookmark2->getTags());
$this->container->bookmarkService->expects(static::exactly(2))->method('get')
->withConsecutive([123], [456])
->willReturnOnConsecutiveCalls($bookmark1, $bookmark2);
$this->container->bookmarkService->expects(static::exactly(2))->method('set')
->withConsecutive([$bookmark1, false], [$bookmark2, false]);
$this->container->bookmarkService->expects(static::once())->method('save');
$this->container->formatterFactory = $this->createMock(FormatterFactory::class);
$this->container->formatterFactory
->expects(static::once())
->method('getFormatter')
->with('raw')
->willReturnCallback(function (): BookmarkFormatter {
return new BookmarkRawFormatter($this->container->conf, true);
})
;
// Make sure that PluginManager hook is triggered
$this->container->pluginManager
->expects(static::exactly(2))
->method('executeHooks')
->with('save_link')
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(['third', 'other'], $bookmark1->getTags());
static::assertSame([], $bookmark2->getTags());
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Test add a tag without passing an ID.
*/
public function testAddTagWithoutId(): void
{
$parameters = ['tag' => 'newtag', 'action' => 'add'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$this->container->sessionManager
->expects(static::once())
->method('setSessionParameter')
->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Test add a tag without passing an ID.
*/
public function testDeleteTagWithoutId(): void
{
$parameters = ['tag' => 'newtag', 'action' => 'delete'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$this->container->sessionManager
->expects(static::once())
->method('setSessionParameter')
->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Test add a tag without passing an action.
*/
public function testAddTagWithoutAction(): void
{
$parameters = ['id' => '123', 'tag' => 'newtag'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$this->container->sessionManager
->expects(static::once())
->method('setSessionParameter')
->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid action provided.'])
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Test add a tag without passing a tag string value.
*/
public function testAddTagWithoutValue(): void
{
$parameters = ['id' => '123', 'tag' => '', 'action' => 'add'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$this->container->sessionManager
->expects(static::once())
->method('setSessionParameter')
->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid tag name provided.'])
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
/**
* Test delete a tag without passing a tag string value.
*/
public function testDeleteTagWithoutValue(): void
{
$parameters = ['id' => '123', 'tag' => '', 'action' => 'delete'];
$request = $this->createMock(Request::class);
$request
->method('getParam')
->willReturnCallback(function (string $key) use ($parameters): ?string {
return $parameters[$key] ?? null;
})
;
$response = new Response();
$this->container->sessionManager
->expects(static::once())
->method('setSessionParameter')
->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid tag name provided.'])
;
$result = $this->controller->addOrDeleteTags($request, $response);
static::assertSame(302, $result->getStatusCode());
static::assertSame(['/subfolder/'], $result->getHeader('location'));
}
}

View file

@ -131,10 +131,46 @@
<a href="" class="actions-change-visibility button" data-visibility="private">
<i class="fa fa-user-secret" aria-hidden="true"></i>
{'Set private'|t}
</a>&nbsp;
<a href="" class="subheader-opener button" data-open-id="bulk-tag-action-add">
<i class="fa fa-tag" aria-hidden="true"></i>
{'Add tag'|t}
</a>&nbsp;
<a href="" class="subheader-opener button" data-open-id="bulk-tag-action-delete">
<i class="fa fa-window-close" aria-hidden="true"></i>
{'Delete tag'|t}
</a>
</div>
</div>
</div>
{$addDelete=['add', 'delete']}
{loop="$addDelete"}
<div id="bulk-tag-action-{$value}" class="subheader-form">
<form class="pure-g" action="{$base_path}/admin/shaare/update-tags" method="post">
<div class="pure-u-1">
<span>
<input
type="text" name="tag" class="autofocus"
aria-label="{$value === 'add' ? t('Tag to add') : t('Tag to delete')}"
placeholder="{$value === 'add' ? t('Tag to add') : t('Tag to delete')}"
autocomplete="off" data-multiple data-autofirst data-minChars="1"
data-list="{loop="$tags"}{$key}, {/loop}"
>
<input type="hidden" name="action" value="{$value}" />
<input type="hidden" name="id" value="" />
<input type="hidden" name="token" value="{$token}" />
</span>&nbsp;
<a href="" class="button action">
<i class="fa fa-tag" aria-hidden="true"></i>
{$value === 'add' ? t('Add tag') : t('Delete tag')}
</a>&nbsp;
<a href="" class="subheader-opener button cancel" data-open-id="actions">{'Cancel'|t}</a>
</div>
</form>
</div>
{/loop}
{if="!$is_logged_in"}
<form method="post" name="loginform">
<div class="subheader-form header-login-form" id="header-login-form">