[CONTROLLER][UserPanel][COMPONENT][Tag] Re-add way of adding self tags, but in a more reusable (and less buggy) way

This commit is contained in:
Hugo Sales 2021-12-23 14:04:00 +00:00 committed by Diogo Peralta Cordeiro
parent 072caad845
commit 95783d6109
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
8 changed files with 199 additions and 65 deletions

View File

@ -6,8 +6,17 @@ namespace Component\Tag\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Component\Tag\Form\SelfTagsForm;
use Component\Tag\Tag as CompTag;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
class Tag extends Controller
{
@ -76,4 +85,90 @@ class Tag extends Controller
template: 'actor_tag_feed.html.twig',
);
}
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
{
$actor = Common::actor();
if (!$actor->canAdmin($target)) {
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
}
$actor_tags = $target->getSelfTags();
[$add_form, $existing_form] = SelfTagsForm::handleTags(
$request,
$actor_tags,
handle_new: function ($form) use ($request, $target, $details_id) {
$data = $form->getData();
$tags = $data['new-tags'];
$language = $target->getTopLanguage()->getLocale();
foreach ($tags as $tag) {
$tag = CompTag::ensureValid($tag);
[$at, ] = E\ActorTag::createOrUpdate([
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'canonical' => CompTag::canonicalTag($tag, language: $language),
'use_canonical' => $data['new-tags-use-canon'],
]);
DB::persist($at);
}
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
throw new RedirectException($request->get('_route'), ['open' => $details_id]);
},
handle_existing: function ($form, array $form_definition) use ($request, $target, $details_id) {
$data = $form->getData();
$changed = false;
foreach (array_chunk($form_definition, 3) as $entry) {
$tag = Formatting::removePrefix($entry[0][2]['data'], '#');
$use_canon = $entry[1][2]['attr']['data'];
/** @var SubmitButton $remove */
$remove = $form->get($entry[2][0]);
if ($remove->isClicked()) {
$changed = true;
DB::removeBy(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'use_canonical' => $use_canon,
],
);
}
/** @var SubmitButton $toggle_canon */
$toggle_canon = $form->get($entry[1][0]);
if ($toggle_canon->isSubmitted()) {
$changed = true;
$at = DB::find(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'use_canonical' => $use_canon,
],
);
DB::persist($at->setUseCanonical(!$use_canon));
}
}
if ($changed) {
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
throw new RedirectException($request->get('_route'), ['open' => $details_id]);
}
},
remove_label: _m('Remove self tag'),
add_label: _m('Add self tag'),
);
return [
'_template' => 'self_tags_settings.fragment.html.twig',
'add_self_tags_form' => $add_form->createView(),
'existing_self_tags_form' => $existing_form?->createView(),
];
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types = 1);
namespace Component\Tag\Form;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Form\ArrayTransformer;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm
{
/**
* @param E\ActorTag[]|E\ActorTagBlock[]|E\NoteTagBlock[] $tags
*
* @return array [Form (add), ?Form (existing)]
*/
public static function handleTags(
Request $request,
array $tags,
callable $handle_new,
callable $handle_existing,
string $remove_label,
string $add_label,
): array {
$form_definition = [];
foreach ($tags as $tag) {
$canon = $tag->getCanonical();
$form_definition[] = ["{$canon}:old-tag", TextType::class, ['data' => '#' . $tag->getTag(), 'label' => ' ', 'disabled' => true]];
$form_definition[] = ["{$canon}:toggle-canon", SubmitType::class, ['attr' => ['data' => $tag->getUseCanonical()], 'label' => $tag->getUseCanonical() ? _m('Set non-canonical') : _m('Set canonical')]];
$form_definition[] = [$existing_form_name = "{$canon}:remove", SubmitType::class, ['label' => $remove_label]];
}
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
$add_form = Form::create([
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
['new-tags-use-canon', CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Assume this tag is the same as similar tags'), 'required' => false, 'data' => true]],
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
]);
if ($request->getMethod() === 'POST' && $request->request->has($add_form_name)) {
$add_form->handleRequest($request);
if ($add_form->isSubmitted() && $add_form->isValid()) {
$handle_new($add_form);
}
}
if (!\is_null($existing_form) && $request->getMethod() === 'POST' && $request->request->has($existing_form_name ?? '')) {
$existing_form->handleRequest($request);
if ($existing_form->isSubmitted() && $existing_form->isValid()) {
$handle_existing($existing_form, $form_definition);
}
}
return [$add_form, $existing_form];
}
}

View File

@ -34,9 +34,11 @@ use App\Entity\ActorTag;
use App\Entity\Language;
use App\Entity\Note;
use App\Entity\NoteTag;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Formatting;
use App\Util\HTML;
use Component\Tag\Controller as C;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
@ -122,7 +124,12 @@ class Tag extends Component
public static function ensureValid(string $tag)
{
return self::ensureLength(str_replace('#', '', $tag));
$tag = self::ensureLength(Formatting::removePrefix($tag, '#'));
if (preg_match(self::TAG_REGEX, '#' . $tag)) {
return $tag;
} else {
throw new ClientException(_m('Invalid tag given: {tag}', ['{tag}' => $tag]));
}
}
public static function ensureLength(string $tag): string
@ -192,4 +199,17 @@ class Tag extends Component
$extra_args['tag_use_canonical'] = $data['tag_use_canonical'];
return Event::next;
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs)
{
if ($section === 'profile' && $request->get('_route') === 'settings') {
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on yourself',
'id' => 'settings-self-tags',
'controller' => C\Tag::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
];
}
return Event::next;
}
}

View File

@ -0,0 +1,4 @@
{% if existing_self_tags_form is defined and existing_self_tags_form is not null %}
{{ form(existing_self_tags_form) }}
{% endif %}
{{ form(add_self_tags_form) }}

View File

@ -36,7 +36,6 @@ use App\Util\Exception\NotFoundException;
use App\Util\Formatting;
use App\Util\Nickname;
use Component\Avatar\Avatar;
use Component\Tag\Tag as TagComponent;
use DateTimeInterface;
use Functional as F;
@ -251,8 +250,10 @@ class Actor extends Entity
public const BUSINESS = 4;
public const BOT = 5;
public static function cacheKeys(int $actor_id, mixed $other = null): array
public static function cacheKeys(int|self $actor_id, mixed $other = null): array
{
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
return [
'id' => "actor-id-{$actor_id}",
'nickname' => "actor-nickname-id-{$actor_id}",
@ -339,7 +340,7 @@ class Actor extends Entity
/**
* Tags attributed to self, shortcut function for increased legibility
*
* @return array<int, array> [ActorCircle[], ActorTag[]] resulting lists
* @return ActorTag[] resulting lists
*/
public function getSelfTags(bool $_test_force_recompute = false): array
{
@ -356,7 +357,7 @@ class Actor extends Entity
* @param null|int $offset Offset from latest
* @param null|int $limit Max number to get
*
* @return array<int, array> [ActorCircle[], ActorTag[]] resulting lists
* @return ActorTag[] resulting lists
*/
public function getOtherTags(self|int|null $context = null, ?int $offset = null, ?int $limit = null, bool $_test_force_recompute = false): array
{
@ -365,11 +366,8 @@ class Actor extends Entity
self::cacheKeys($this->getId())['tags'],
fn () => DB::dql(
<<< 'EOQ'
SELECT circle, tag
SELECT tag
FROM actor_tag tag
JOIN actor_circle circle
WITH tag.tagger = circle.tagger
AND tag.tag = circle.tag
WHERE tag.tagged = :id
ORDER BY tag.modified DESC, tag.tagged DESC
EOQ,
@ -383,58 +381,18 @@ class Actor extends Entity
self::cacheKeys($this->getId(), $context_id)['tags'],
fn () => DB::dql(
<<< 'EOQ'
SELECT circle, tag
SELECT tag
FROM actor_tag tag
JOIN actor_circle circle
WITH tag.tagger = circle.tagger
AND tag.tag = circle.tag
WHERE
tag.tagged = :id
AND (circle.private != true
OR (circle.tagger = :scoped
AND circle.private = true
)
)
WHERE tag.tagged = :tagged_id AND tag.tagger = :tagger_id
ORDER BY tag.modified DESC, tag.tagged DESC
EOQ,
['id' => $this->getId(), 'scoped' => $context_id],
['tagged_id' => $this->getId(), 'tagger_id' => $context_id],
options: ['offset' => $offset, 'limit' => $limit],
),
);
}
}
/**
* @param array $tags array of strings to become self tags
* @param null|array $existing array of existing self tags (ActorTag[])
*
* @return $this
*/
public function setSelfTags(array $tags, ?array $existing = null): self
{
$tags = F\filter($tags, fn ($tag) => Nickname::isCanonical($tag)); // TODO: Have an actual #Tag test
$tags = array_unique($tags);
if (\is_null($existing)) {
[$_, $existing] = $this->getSelfTags();
}
$existing_actor_tags = F\map($existing, fn ($actor_tag) => $actor_tag->getTag());
$tags_to_add = array_diff($tags, $existing_actor_tags);
$tags_to_remove = array_diff($existing_actor_tags, $tags);
$actor_tags_to_remove = F\filter($existing, fn ($actor_tag) => \in_array($actor_tag->getTag(), $tags_to_remove));
foreach ($tags_to_add as $tag) {
$canonical_tag = TagComponent::canonicalTag($tag, $this->getTopLanguage()->getLocale());
DB::persist(ActorCircle::create(['tagger' => $this->getId(), 'tag' => $tag, 'private' => false]));
DB::persist(ActorTag::create(['tagger' => $this->id, 'tagged' => $this->id, 'tag' => $tag, 'canonical' => $canonical_tag, 'use_canonical' => false])); // TODO make use canonical configurable
}
foreach ($actor_tags_to_remove as $actor_tag) {
DB::removeBy('actor_tag', ['tagger' => $this->getId(), 'tagged' => $this->getId(), 'tag' => $actor_tag->getTag(), 'use_canonical' => $actor_tag->getUseCanonical()]);
DB::removeBy('actor_circle', ['tagger' => $this->getId(), 'tag' => $actor_tag->getTag()]); // TODO only remove if unused
}
Cache::delete(self::cacheKeys($this->getId())['tags']);
Cache::delete(self::cacheKeys($this->getId(), $this->getId())['tags']);
return $this;
}
private function getSubCount(string $which, string $column): int
{
return Cache::get(
@ -602,6 +560,8 @@ class Actor extends Entity
public function canAdmin(self $other): bool
{
switch ($other->getType()) {
case self::PERSON:
return $this->getId() === $other->getId();
case self::GROUP:
return Cache::get(
self::cacheKeys($this->getId(), $other->getId())['can-admin'],

View File

@ -127,17 +127,9 @@ class ActorTag extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKey(int|Actor $actor_id)
{
if (!\is_int($actor_id)) {
$actor_id = $actor_id->getId();
}
return "actor-tags-{$actor_id}";
}
public static function getByActorId(int $actor_id): array
{
return Cache::getList(self::cacheKey($actor_id), fn () => DB::dql('select at from actor_tag at join actor a with a.id = at.tagger where a.id = :id', ['id' => $actor_id]));
return Cache::getList(Actor::cacheKeys($actor_id)['tags'], fn () => DB::dql('select at from actor_tag at join actor a with a.id = at.tagger where a.id = :id', ['id' => $actor_id]));
}
public function getUrl(?Actor $actor = null): string
@ -163,9 +155,9 @@ class ActorTag extends Entity
],
'primary key' => ['tagger', 'tagged', 'tag', 'use_canonical'],
'indexes' => [
'actor_tag_modified_idx' => ['modified'],
'actor_tag_modified_idx' => ['modified'],
'actor_tag_tagger_tag_idx' => ['tagger', 'tag'], // For Circles
'actor_tag_tagged_idx' => ['tagged'],
'actor_tag_tagged_idx' => ['tagged'],
],
];
}

View File

@ -1,7 +1,7 @@
{% set actor_nickname = actor.getNickname() %}
{% set actor_avatar = actor.getAvatarUrl() %}
{% set actor_avatar_dimensions = actor.getAvatarDimensions() %}
{% set actor_tags = actor.getSelfTags()[1] %} {# Take only the actor_tags, not the circles #}
{% set actor_tags = actor.getSelfTags() %}
{% set actor_bio = actor.getBio() %}
{% set actor_uri = actor.getUri() %}

View File

@ -15,7 +15,7 @@
<h2>Settings</h2>
<ul>
<li>
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio, Self Tags and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
{% set profile_tabs = profile_tabs|merge(handle_event('PopulateSettingsTabs', app.request, 'profile')) %}
{{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }}
</li>