diff --git a/components/Tag/Controller/Tag.php b/components/Tag/Controller/Tag.php index eea9eed65d..4784e4fdf8 100644 --- a/components/Tag/Controller/Tag.php +++ b/components/Tag/Controller/Tag.php @@ -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(), + ]; + } } diff --git a/components/Tag/Form/SelfTagsForm.php b/components/Tag/Form/SelfTagsForm.php new file mode 100644 index 0000000000..499becf8bc --- /dev/null +++ b/components/Tag/Form/SelfTagsForm.php @@ -0,0 +1,63 @@ +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]; + } +} diff --git a/components/Tag/Tag.php b/components/Tag/Tag.php index d24aa2429a..21e1c16230 100644 --- a/components/Tag/Tag.php +++ b/components/Tag/Tag.php @@ -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; + } } diff --git a/components/Tag/templates/self_tags_settings.fragment.html.twig b/components/Tag/templates/self_tags_settings.fragment.html.twig new file mode 100644 index 0000000000..7824364751 --- /dev/null +++ b/components/Tag/templates/self_tags_settings.fragment.html.twig @@ -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) }} diff --git a/src/Entity/Actor.php b/src/Entity/Actor.php index b3d94f11ae..422915100d 100644 --- a/src/Entity/Actor.php +++ b/src/Entity/Actor.php @@ -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 [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 [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'], diff --git a/src/Entity/ActorTag.php b/src/Entity/ActorTag.php index 9cab461a47..054511095f 100644 --- a/src/Entity/ActorTag.php +++ b/src/Entity/ActorTag.php @@ -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'], ], ]; } diff --git a/templates/cards/profile/view.html.twig b/templates/cards/profile/view.html.twig index aa6586ec64..1f1d417afb 100644 --- a/templates/cards/profile/view.html.twig +++ b/templates/cards/profile/view.html.twig @@ -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() %} diff --git a/templates/settings/base.html.twig b/templates/settings/base.html.twig index 0b2dc8af0d..9b4eafb179 100644 --- a/templates/settings/base.html.twig +++ b/templates/settings/base.html.twig @@ -15,7 +15,7 @@

Settings