[COMPONENT][Tag] Improve Note Tag Handling and start extracting Circles logic out of the plugin, various bug fixes

This commit is contained in:
Diogo Peralta Cordeiro 2022-01-04 22:20:12 +00:00
parent ee007befa4
commit 627d92b290
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
13 changed files with 180 additions and 473 deletions

View File

@ -48,7 +48,7 @@ class Search extends FeedController
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
$q = $this->string('q');
$data = $this->query(query: $q, language: $language);
$data = $this->query(query: $q, locale: $language);
$notes = $data['notes'];
$actors = $data['actors'];

View File

@ -6,31 +6,34 @@ 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\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Component\Tag\Form\SelfTagsForm;
use Component\Language\Entity\Language;
use Component\Tag\Tag as CompTag;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
class Tag extends Controller
{
private function process(string|array $canon_single_or_multi, null|string|array $tag_single_or_multi, string $key, string $query, string $template)
// TODO: Use Feed::query
// TODO: If ?canonical=something, respect
// TODO: Allow to set locale of tag being selected
private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false)
{
$actor = Common::actor();
$page = $this->int('page') ?: 1;
$lang = $this->string('lang');
$query_args = ['tag' => $tag_single_or_multi];
if ($include_locale) {
if (!\is_null($locale = $this->string('locale'))) {
$query_args['language_id'] = Language::getByLocale($locale)->getId();
} else {
$query_args['language_id'] = Common::actor()->getTopLanguage()->getId();
}
}
$results = Cache::pagedStream(
key: $key,
query: $query,
query_args: ['canon' => $canon_single_or_multi],
query_args: $query_args,
actor: $actor,
page: $page,
);
@ -43,179 +46,25 @@ class Tag extends Controller
];
}
public function single_note_tag(string $canon)
public function single_note_tag(string $tag)
{
return $this->process(
canon_single_or_multi: $canon,
tag_single_or_multi: $this->string('tag'),
key: CompTag::cacheKeys($canon)['note_single'],
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical = :canon order by nt.created DESC, nt.note_id DESC',
tag_single_or_multi: $tag,
key: CompTag::cacheKeys($tag)['note_single'],
query: 'SELECT n FROM note AS n JOIN note_tag AS nt WITH n.id = nt.note_id WHERE nt.tag = :tag AND nt.language_id = :language_id ORDER BY nt.created DESC, nt.note_id DESC',
template: 'note_tag_feed.html.twig',
include_locale: true,
);
}
public function multi_note_tags(string $canons)
public function multi_note_tags(string $tags)
{
return $this->process(
canon_single_or_multi: explode(',', $canons),
tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null,
key: CompTag::cacheKeys(str_replace(',', '-', $canons))['note_multi'],
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical in (:canon) order by nt.created DESC, nt.note_id DESC',
tag_single_or_multi: explode(',', $tags),
key: CompTag::cacheKeys(str_replace(',', '-', $tags))['note_multi'],
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.tag in (:tag) AND nt.language_id = :language_id order by nt.created DESC, nt.note_id DESC',
template: 'note_tag_feed.html.twig',
include_locale: true,
);
}
public function single_actor_tag(string $canon)
{
return $this->process(
canon_single_or_multi: $canon,
tag_single_or_multi: $this->string('tag'),
key: CompTag::cacheKeys($canon)['actor_single'],
query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC',
template: 'actor_tag_feed.html.twig',
);
}
public function multi_actor_tag(string $canons)
{
return $this->process(
canon_single_or_multi: explode(',', $canons),
tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null,
key: CompTag::cacheKeys(str_replace(',', '-', $canons))['actor_multi'],
query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC',
template: 'actor_tag_feed.html.twig',
);
}
/**
* Generic settings page for an Actor's self tags
*/
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: /**
* Handle adding tags
*/
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);
$canon_tag = CompTag::canonicalTag($tag, language: $language);
$use_canon = $data['new-tags-use-canon'];
[$actor_tag, $actor_tag_existed] = E\ActorTag::createOrUpdate([
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'canonical' => $canon_tag,
'use_canonical' => $use_canon,
]);
DB::persist($actor_tag);
$actor_circle = DB::findBy(
'actor_circle',
[
'tagger' => null,
'tagged' => $target->getId(),
'in' => ['tag' => [$tag, $canon_tag]],
'use_canonical' => $use_canon,
],
);
if (empty($actor_circle)) {
if ($actor_tag_existed) {
throw new BugFoundException('Actor tag existed but generic actor circle did not');
}
DB::persist(E\ActorCircle::create([
'tagger' => null,
'tagged' => $target->getId(),
'tag' => $use_canon ? $canon_tag : $tag,
'use_canonical' => $use_canon,
'private' => false,
'description' => null,
]));
}
}
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]);
},
handle_existing: /**
* Handle changes to the existing tags
*/
function ($form, array $form_definition) use ($request, $target, $details_id) {
$data = $form->getData();
$changed = false;
$language = $target->getTopLanguage()->getLocale();
foreach (array_chunk($form_definition, 3) as $entry) {
$tag = Formatting::removePrefix($entry[0][2]['data'], '#');
$canon_tag = CompTag::canonicalTag($tag, language: $language);
$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,
],
);
DB::removeBy(
'actor_circle',
[
'tagger' => null,
'tagged' => $target->getId(),
'tag' => $use_canon ? $canon_tag : $tag,
'use_canonical' => $use_canon,
],
);
}
/** @var SubmitButton $toggle_canon */
$toggle_canon = $form->get($entry[1][0]);
if ($toggle_canon->isSubmitted()) {
$changed = true;
$actor_tag = DB::find(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'use_canonical' => $use_canon,
],
);
DB::persist($actor_tag->setUseCanonical(!$use_canon));
}
}
if ($changed) {
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), '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

@ -19,12 +19,15 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Tag\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Note;
use Component\Language\Entity\Language;
use Component\Tag\Tag;
use DateTimeInterface;
@ -39,6 +42,7 @@ use DateTimeInterface;
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
@ -51,11 +55,11 @@ class NoteTag extends Entity
private int $note_id;
private bool $use_canonical;
private ?int $language_id = null;
private \DateTimeInterface $created;
private DateTimeInterface $created;
public function setTag(string $tag): self
{
$this->tag = \mb_substr($tag, 0, 64);
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@ -66,7 +70,7 @@ class NoteTag extends Entity
public function setCanonical(string $canonical): self
{
$this->canonical = \mb_substr($canonical, 0, 64);
$this->canonical = mb_substr($canonical, 0, 64);
return $this;
}
@ -108,13 +112,13 @@ class NoteTag extends Entity
return $this->language_id;
}
public function setCreated(\DateTimeInterface $created): self
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): \DateTimeInterface
public function getCreated(): DateTimeInterface
{
return $this->created;
}
@ -132,15 +136,24 @@ class NoteTag extends Entity
public static function getByNoteId(int $note_id): array
{
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('select nt from note_tag nt join note n with n.id = nt.note_id where n.id = :id', ['id' => $note_id]));
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('SELECT nt FROM note_tag AS nt JOIN note AS n WITH n.id = nt.note_id WHERE n.id = :id', ['id' => $note_id]));
}
public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string
{
$params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()];
if (!\is_null($actor)) {
$params['lang'] = $actor->getTopLanguage()->getLocale();
$params['tag'] = $this->getTag();
if (\is_null($this->getLanguageId())) {
if (!\is_null($actor)) {
$params['locale'] = $actor->getTopLanguage()->getLocale();
}
} else {
$params['locale'] = Language::getById($this->getLanguageId())->getLocale();
}
if ($this->getUseCanonical()) {
$params['canonical'] = $this->getCanonical();
}
return Router::url(id: 'single_note_tag', args: $params, type: $type);
}
@ -150,18 +163,18 @@ class NoteTag extends Entity
'name' => 'note_tag',
'description' => 'Hash tags on notes',
'fields' => [
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'],
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this note'],
'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'],
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'],
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to use canonical tags in this note. Separate for blocks'],
'language_id' => ['type' => 'int', 'not null' => false, 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'many to many', 'description' => 'the language this entry refers to'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['tag', 'note_id'],
'primary key' => ['note_id', 'tag'], // No need to require language in this association because all the related tags will be in the note's language already
'indexes' => [
'note_tag_created_idx' => ['created'],
'note_tag_note_id_idx' => ['note_id'],
'note_tag_canonical_idx' => ['canonical'],
'note_tag_tag_language_id_idx' => ['tag', 'language_id'],
'note_tag_tag_created_note_id_idx' => ['tag', 'created', 'note_id'],
],
];

View File

@ -19,7 +19,7 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Tag\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
@ -46,7 +46,7 @@ class NoteTagBlock extends Entity
private string $tag;
private string $canonical;
private bool $use_canonical;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setBlocker(int $blocker): self
{
@ -61,7 +61,7 @@ class NoteTagBlock extends Entity
public function setTag(string $tag): self
{
$this->tag = \mb_substr($tag, 0, 64);
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@ -72,7 +72,7 @@ class NoteTagBlock extends Entity
public function setCanonical(string $canonical): self
{
$this->canonical = \mb_substr($canonical, 0, 64);
$this->canonical = mb_substr($canonical, 0, 64);
return $this;
}
@ -92,13 +92,13 @@ class NoteTagBlock extends Entity
return $this->use_canonical;
}
public function setModified(\DateTimeInterface $modified): self
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): \DateTimeInterface
public function getModified(): DateTimeInterface
{
return $this->modified;
}

View File

@ -1,63 +0,0 @@
<?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

@ -30,18 +30,15 @@ use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\ActorCircle;
use App\Entity\ActorTag;
use App\Entity\Note;
use App\Entity\NoteTag;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Formatting;
use App\Util\Functional as GSF;
use App\Util\HTML;
use App\Util\Nickname;
use Component\Circle\Entity\ActorTag;
use Component\Language\Entity\Language;
use Component\Tag\Controller as C;
use Component\Tag\Entity\NoteTag;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
@ -53,22 +50,20 @@ use Symfony\Component\HttpFoundation\Request;
* Component responsible for extracting tags from posted notes, as well as normalizing them
*
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Tag extends Component
{
public const MAX_TAG_LENGTH = 64;
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
public const MAX_TAG_LENGTH = 64;
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
public function onAddRoute($r): bool
{
$r->connect('single_note_tag', '/note-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
$r->connect('multi_note_tags', '/note-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
$r->connect('single_actor_tag', '/actor-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_actor_tag']);
$r->connect('multi_actor_tags', '/actor-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_actor_tags']);
$r->connect('single_note_tag', '/note-tag/{tag<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
$r->connect('multi_note_tags', '/note-tags/{tags<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
return Event::next;
}
@ -86,7 +81,10 @@ class Tag extends Component
preg_match_all(self::TAG_REGEX, $content, $matched_tags, \PREG_SET_ORDER);
$matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2]));
foreach ($matched_tags as $match) {
$tag = self::ensureValid($match);
$tag = self::extract($match);
if (!self::validate($tag)) {
continue; // Ignore invalid tag candidates
}
$canonical_tag = self::canonicalTag($tag, \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
DB::persist(NoteTag::create([
'tag' => $tag,
@ -103,38 +101,54 @@ class Tag extends Component
return Event::next;
}
public function onRenderPlainTextNoteContent(string &$text, ?string $language = null): bool
public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): bool
{
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $language), $text);
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text);
return Event::next;
}
public static function cacheKeys(string $canon_single_or_multi): array
public static function cacheKeys(string $tag_single_or_multi): array
{
return [
'note_single' => "note-tag-feed-{$canon_single_or_multi}",
'note_multi' => "note-tags-feed-{$canon_single_or_multi}",
'actor_single' => "actor-tag-feed-{$canon_single_or_multi}",
'actor_multi' => "actor-tags-feed-{$canon_single_or_multi}",
'note_single' => "note-tag-feed-{$tag_single_or_multi}",
'note_multi' => "note-tags-feed-{$tag_single_or_multi}",
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
];
}
private static function tagLink(string $tag, ?string $language): string
private static function tagLink(string $tag, ?string $locale): string
{
$tag = self::ensureLength($tag);
$canonical = self::canonicalTag($tag, $language);
$url = Router::url('single_note_tag', !\is_null($language) ? ['canon' => $canonical, 'lang' => $language, 'tag' => $tag] : ['canon' => $canonical, 'tag' => $tag]);
return HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => $tag, 'rel' => 'tag'], $tag]], options: ['indent' => false]);
$tag = self::extract($tag);
$url = Router::url('single_note_tag', !\is_null($locale) ? ['tag' => $tag, 'locale' => $locale] : ['tag' => $tag]);
return HTML::html(['span' => ['attrs' => ['class' => 'tag'],
'#' . HTML::html(['a' => [
'attrs' => [
'href' => $url,
'rel' => 'tag', // https://microformats.org/wiki/rel-tag
],
$tag,
]], options: ['indent' => false]),
]], options: ['indent' => false, 'raw' => true]);
}
public static function ensureValid(string $tag)
public static function extract(string $tag): string
{
$tag = self::ensureLength(Formatting::removePrefix($tag, '#'));
if (preg_match(self::TAG_REGEX, '#' . $tag)) {
return $tag;
} else {
return self::ensureLength(Formatting::removePrefix($tag, '#'));
}
public static function validate(string $tag): bool
{
return preg_match(self::TAG_REGEX, '#' . $tag) === 1;
}
public static function sanitize(string $tag): string
{
$tag = self::extract($tag);
if (!self::validate($tag)) {
throw new ClientException(_m('Invalid tag given: {tag}', ['{tag}' => $tag]));
}
return $tag;
}
public static function ensureLength(string $tag): string
@ -143,11 +157,11 @@ class Tag extends Component
}
/**
* Convert a tag to it's canonical representation, by splitting it
* Convert a tag to its canonical representation, by splitting it
* into words, stemming it in the given language (if enabled) and
* sluggifying it (turning it into an ASCII representation)
*/
public static function canonicalTag(string $tag, ?string $language): string
public static function canonicalTag(string $tag, ?string $language = null): string
{
$result = '';
foreach (Formatting::splitWords(str_replace('#', '', $tag)) as $word) {
@ -165,17 +179,20 @@ class Tag extends Component
*
* $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
if (!str_contains($term, ':')) {
return Event::next;
}
if (\is_null($locale)) {
$locale = Common::currentLanguage();
}
[$search_type, $search_term] = explode(':', $term);
if (str_starts_with($search_term, '#')) {
$search_term = self::ensureValid($search_term);
$canon_search_term = self::canonicalTag($search_term, $language);
$temp_note_expr = $eb->eq('note_tag.canonical', $canon_search_term);
$temp_actor_expr = $eb->eq('actor_tag.canonical', $canon_search_term);
$search_term = self::sanitize($search_term);
$canonical_search_term = self::canonicalTag($search_term, $locale);
$temp_note_expr = $eb->eq('note_tag.canonical', $canonical_search_term);
$temp_actor_expr = $eb->eq('actor_tag.canonical', $canonical_search_term);
if (Formatting::startsWith($term, ['note:', 'tag:', 'people:'])) {
$note_expr = $temp_note_expr;
} elseif (Formatting::startsWith($term, ['people:', 'actor:'])) {
@ -183,7 +200,7 @@ class Tag extends Component
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['people', 'actor'], ['circle', 'list'], [':']], separator: ['-', '_']))) {
$null_tagger_expr = $eb->isNull('actor_circle.tagger');
$tagger_expr = \is_null($actor_expr) ? $null_tagger_expr : $eb->orX($null_tagger_expr, $eb->eq('actor_circle.tagger', $actor->getId()));
$tags = array_unique([$search_term, $canon_search_term]);
$tags = array_unique([$search_term, $canonical_search_term]);
$tag_expr = \count($tags) === 1 ? $eb->eq('actor_circle.tag', $tags[0]) : $eb->in('actor_circle.tag', $tags);
$search_expr = $eb->andX(
$tagger_expr,
@ -202,52 +219,23 @@ class Tag extends Component
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id')
->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'note_actor.id = actor_circle.tagged');
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id')
->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'actor.id = actor_circle.tagged');
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id');
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id');
return Event::next;
}
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params)
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): bool
{
$form_params[] = ['tag_use_canonical', CheckboxType::class, ['required' => false, 'data' => true, 'label' => _m('Make note tags canonical'), 'help' => _m('Canonical tags will be treated as a version of an existing tag with the same root/stem (e.g. \'#great_tag\' will be considered as a version of \'#great\', if it already exists)')]];
return Event::next;
}
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args)
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
{
if (!isset($data['tag_use_canonical'])) {
throw new ClientException;
throw new ClientException(_m('Missing Use Canonical preference for Tags.'));
}
$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;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets)
{
$actor_id = $actor->getId();
$tags = Cache::get(
"actor-circle-{$actor_id}",
fn () => DB::dql('select c.tag from actor_circle c where c.tagger = :tagger', ['tagger' => $actor_id]),
);
foreach ($tags as $t) {
$t = '#' . $t['tag'];
$targets[$t] = $t;
}
return Event::next;
}
}

View File

@ -44,7 +44,6 @@ use App\Core\Log;
use App\Core\Router\Router;
use App\Core\VisibilityScope;
use App\Entity\Note as GSNote;
use App\Entity\NoteTag;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
@ -57,6 +56,7 @@ use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Component\FreeNetwork\FreeNetwork;
use Component\Language\Entity\Language;
use Component\Tag\Entity\NoteTag;
use Component\Tag\Tag;
use DateTime;
use DateTimeInterface;
@ -254,7 +254,7 @@ class Note extends Model
break;
case 'Hashtag':
$match = ltrim($ap_tag->get('name'), '#');
$tag = Tag::ensureValid($match);
$tag = Tag::extract($match);
$canonical_tag = $ap_tag->get('canonical') ?? Tag::canonicalTag($tag, \is_null($lang_id = $obj->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
DB::persist(NoteTag::create([
'tag' => $tag,

View File

@ -25,9 +25,8 @@ use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Plugin;
use App\Entity\ActorTag;
use App\Entity\NoteTag;
use Component\Circle\Entity\ActorTag;
use Component\Tag\Entity\NoteTag;
use Symfony\Component\HttpFoundation\Request;
class RelatedTags extends Plugin
@ -37,21 +36,25 @@ class RelatedTags extends Plugin
*/
public function onAddPinnedFeedContent(Request $request, array &$pinned)
{
$tags = $request->attributes->get('canons');
$tags = !\is_null($tags) ? explode(',', $tags) : [$request->attributes->get('canon')];
// Lets not use language, probably wouldn't make it more helpful
//$locale = $request->attributes->get('locale');
//$language_id = !empty($locale) ? Language::getByLocale($locale)->getId() : Common::actor()->getTopLanguage()->getId();
$tags = $request->attributes->get('tags');
$tags = !\is_null($tags) ? explode(',', $tags) : [$request->attributes->get('tag')];
switch ($request->attributes->get('_route')) {
case 'single_note_tag':
// fall-through
case 'multi_note_tags':
$related = Cache::getList(
//"related-note-tags-{$language_id}-" . implode('-', $tags),
'related-note-tags-' . implode('-', $tags),
fn () => DB::sql(
<<<'EOQ'
select distinct on (nt.canonical) canonical, nt.tag, nt.note_id, nt.created
select distinct on (nt.canonical) canonical, nt.tag, nt.note_id, nt.canonical, nt.use_canonical, nt.created
from note_tag nt
where nt.note_id in (select n.id from note n join note_tag nt on n.id = nt.note_id where nt.canonical in (:tags))
and not nt.canonical in (:tags)
where nt.note_id in (select n.id from note n join note_tag nt on n.id = nt.note_id where nt.tag in (:tags))
and not nt.tag in (:tags)
limit 5
EOQ,
['tags' => $tags],
@ -68,10 +71,10 @@ class RelatedTags extends Plugin
'related-actor-tags-' . implode('-', $tags),
fn () => DB::sql(
<<<'EOQ'
select distinct on (at.canonical) canonical, at.tagger, at.tagged, at.tag, at.use_canonical, at.modified
select distinct on (at.tag) tag, at.tagger, at.tagged, at.tag, at.modified
from actor_tag at
where at.tagged in (select at.tagged from actor_tag at where at.canonical in (:tags))
and not at.canonical in (:tags)
where at.tagged in (select at.tagged from actor_tag at where at.tag in (:tags))
and not at.tag in (:tags)
limit 5
EOQ,
['tags' => $tags],

View File

@ -29,14 +29,12 @@ use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\Actor;
use App\Entity\ActorTag;
use App\Entity\ActorTagBlock;
use App\Entity\Note;
use App\Entity\NoteTag;
use App\Entity\NoteTagBlock;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use Component\Language\Entity\Language;
use Component\Tag\Entity\NoteTag;
use Component\Tag\Entity\NoteTagBlock;
use Component\Tag\Tag;
use Functional as F;
use Plugin\TagBasedFiltering\TagBasedFiltering as TagFilerPlugin;
@ -67,10 +65,10 @@ class AddBlocked extends Controller
$form_definition = [];
foreach ($blockable_tags as $nt) {
$canon = $nt->getCanonical();
$form_definition[] = ["{$canon}:tag", TextType::class, ['data' => '#' . $nt->getTag(), 'label' => ' ']];
$form_definition[] = ["{$canon}:use-canon", CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Block all similar tags'), 'required' => false, 'data' => true]];
$form_definition[] = ["{$canon}:add", SubmitType::class, ['label' => _m('Block')]];
$canonical = $nt->getCanonical();
$form_definition[] = ["{$canonical}:tag", TextType::class, ['data' => '#' . $nt->getTag(), 'label' => ' ']];
$form_definition[] = ["{$canonical}:use-canonical", CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Block all similar tags'), 'required' => false, 'data' => true]];
$form_definition[] = ["{$canonical}:add", SubmitType::class, ['label' => _m('Block')]];
}
$form = null;
@ -80,24 +78,24 @@ class AddBlocked extends Controller
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
foreach ($form_definition as [$id, $_, $opts]) {
[$canon, $type] = explode(':', $id);
[$canonical, $type] = explode(':', $id);
if ($type === 'add') {
/** @var SubmitButton $button */
$button = $form->get($id);
if ($button->isClicked()) {
Cache::delete($block_class::cacheKey($user->getId()));
Cache::delete(TagFilerPlugin::cacheKeys($user->getId())[$type_name]);
$new_tag = Tag::ensureValid($data[$canon . ':tag']);
$new_tag = Tag::sanitize($data[$canonical . ':tag']);
$language = $target instanceof Note ? Language::getByNote($target)->getLocale() : $user->getActor()->getTopLanguage()->getLocale();
$canonical_tag = Tag::canonicalTag($new_tag, $language);
DB::persist($block_class::create([
'blocker' => $user->getId(),
'tag' => $new_tag,
'canonical' => $canonical_tag,
'use_canonical' => $data[$canon . ':use-canon'],
'use_canonical' => $data[$canonical . ':use-canonical'],
]));
DB::flush();
throw new RedirectException;
throw new RedirectException();
}
}
}
@ -131,20 +129,4 @@ class AddBlocked extends Controller
block_class: NoteTagBlock::class,
);
}
public function addBlockedActorTags(Request $request, int $actor_id)
{
return self::addBlocked(
request: $request,
type_name: 'actor',
calculate_target: fn () => Actor::getById($actor_id),
calculate_blocks: fn ($user) => ActorTagBlock::getByActorId($user->getId()),
calculate_tags: fn ($blocks) => F\reject(
ActorTag::getByActorId($actor_id),
fn (ActorTag $nt) => ActorTagBlock::checkBlocksActorTag($nt, $blocks),
),
label: _m('Tags of the account above:'),
block_class: ActorTagBlock::class,
);
}
}

View File

@ -28,10 +28,9 @@ use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\ActorTagBlock;
use App\Entity\NoteTagBlock;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use Component\Tag\Entity\NoteTagBlock;
use Component\Tag\Tag;
use Plugin\TagBasedFiltering\TagBasedFiltering as TagFilerPlugin;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -105,7 +104,7 @@ class EditBlocked extends Controller
$data = $add_block_form->getData();
Cache::delete($block_class::cacheKey($user->getId()));
Cache::delete(TagFilerPlugin::cacheKeys($user->getId())[$type_name]);
$new_tag = Tag::ensureValid($data['tag']);
$new_tag = Tag::sanitize($data['tag']);
$language = $user->getActor()->getTopLanguage()->getLocale();
$canonical_tag = Tag::canonicalTag($new_tag, $language);
DB::persist($block_class::create([
@ -137,15 +136,4 @@ class EditBlocked extends Controller
block_class: NoteTagBlock::class,
);
}
public static function editBlockedActorTags(Request $request)
{
return self::editBlocked(
request: $request,
type_name: 'actor',
calculate_blocks: fn ($user) => ActorTagBlock::getByActorId($user->getId()),
label: _m('Add blocked people tag:'),
block_class: ActorTagBlock::class,
);
}
}

View File

@ -31,12 +31,10 @@ use App\Core\Modules\Plugin;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\ActorTag;
use App\Entity\ActorTagBlock;
use App\Entity\LocalUser;
use App\Entity\Note;
use App\Entity\NoteTag;
use App\Entity\NoteTagBlock;
use Component\Tag\Entity\NoteTag;
use Component\Tag\Entity\NoteTagBlock;
use Functional as F;
use Plugin\TagBasedFiltering\Controller as C;
use Symfony\Component\HttpFoundation\Request;
@ -88,10 +86,6 @@ class TagBasedFiltering extends Plugin
self::cacheKeys($actor)['note'],
fn () => DB::dql('select ntb from note_tag_block ntb where ntb.blocker = :blocker', ['blocker' => $actor->getId()]),
);
$blocked_actor_tags = Cache::get(
self::cacheKeys($actor)['actor'],
fn () => DB::dql('select atb from actor_tag_block atb where atb.blocker = :blocker', ['blocker' => $actor->getId()]),
);
$notes = F\reject(
$notes,
@ -102,10 +96,6 @@ class TagBasedFiltering extends Plugin
NoteTag::getByNoteId($n->getId()),
fn ($nt) => NoteTagBlock::checkBlocksNoteTag($nt, $blocked_note_tags),
)
|| F\some(
ActorTag::getByActorId($n->getActor()->getId()),
fn ($at) => ActorTagBlock::checkBlocksActorTag($at, $blocked_actor_tags),
)
)
),
);
@ -122,12 +112,6 @@ class TagBasedFiltering extends Plugin
'id' => 'settings-muting-note-tags',
'controller' => C\EditBlocked::editBlockedNoteTags($request),
];
$tabs[] = [
'title' => 'Muted people tags',
'desc' => 'Edit your muted people tags',
'id' => 'settings-muting-actor-tags',
'controller' => C\EditBlocked::editBlockedActorTags($request),
];
}
return Event::next;
}

View File

@ -142,12 +142,6 @@ class Activity extends Entity
return DB::findOneBy($this->getObjectType(), ['id' => $this->getObjectId()]);
}
public function getNotificationTargetIdsFromActorTags(): array
{
$actor_circles = $this->getActor()->getActorCircles();
return F\flat_map($actor_circles, fn ($circle) => $circle->getSubscribedActors());
}
/**
* Who should be notified about this object?
*
@ -157,13 +151,6 @@ class Activity extends Entity
{
$target_ids = [];
// Actor Circles
if (\array_key_exists('actor_circle', $ids_already_known)) {
array_push($target_ids, ...$ids_already_known['actor_circle']);
} else {
array_push($target_ids, ...$this->getNotificationTargetIdsFromActorTags());
}
// Notifications
if (\array_key_exists('notification_activity', $ids_already_known)) {
array_push($target_ids, ...$ids_already_known['notification_activity']);

View File

@ -257,7 +257,7 @@ class Actor extends Entity
'id' => "actor-id-{$actor_id}",
'nickname' => "actor-nickname-id-{$actor_id}",
'fullname' => "actor-fullname-id-{$actor_id}",
'tags' => \is_null($other) ? "actor-tags-{$actor_id}" : "actor-tags-{$actor_id}-by-{$other}", // $other is $context_id
'self-tags' => "actor-self-tags-{$actor_id}",
'circles' => "actor-circles-{$actor_id}",
'subscriber' => "subscriber-{$actor_id}",
'subscribed' => "subscribed-{$actor_id}",
@ -309,62 +309,20 @@ class Actor extends Entity
}
/**
* Tags attributed to self, shortcut function for increased legibility
*
* @return ActorTag[] resulting lists
* @return array ActorTag[] Self Tag Circles of which this actor is a member
*/
public function getSelfTags(bool $_test_force_recompute = false): array
public function getSelfTags(): array
{
return $this->getOtherTags(context: $this->getId(), _test_force_recompute: $_test_force_recompute);
return Cache::getList(
self::cacheKeys($this->getId())['self-tags'],
fn() => DB::findBy('actor_tag', ['tagger' => $this->getId(), 'tagged' => $this->getId()], order_by: ['modified' => 'DESC']),
);
}
/**
* Get tags that other people put on this actor, in reverse-chron order
*
* @param null|Actor|int $context Actor we are requesting as:
* - If null = All tags attributed to self by other actors (excludes self tags)
* - If self = Same as getSelfTags
* - otherwise = Tags that $context attributed to $this
* @param null|int $offset Offset from latest
* @param null|int $limit Max number to get
*
* @return ActorTag[] resulting lists
* @return array ActorCircle[]
*/
public function getOtherTags(self|int|null $context = null, ?int $offset = null, ?int $limit = null, bool $_test_force_recompute = false): array
{
if (\is_null($context)) {
return Cache::getList(
self::cacheKeys($this->getId())['tags'],
fn() => DB::dql(
<<< 'EOQ'
SELECT tag
FROM actor_tag tag
WHERE tag.tagged = :id
ORDER BY tag.modified DESC, tag.tagged DESC
EOQ,
['id' => $this->getId()],
options: ['offset' => $offset, 'limit' => $limit],
),
);
} else {
$context_id = \is_int($context) ? $context : $context->getId();
return Cache::getList(
self::cacheKeys($this->getId(), $context_id)['tags'],
fn() => DB::dql(
<<< 'EOQ'
SELECT tag
FROM actor_tag tag
WHERE tag.tagged = :tagged_id AND tag.tagger = :tagger_id
ORDER BY tag.modified DESC, tag.tagged DESC
EOQ,
['tagged_id' => $this->getId(), 'tagger_id' => $context_id],
options: ['offset' => $offset, 'limit' => $limit],
),
);
}
}
public function getActorCircles()
public function getCircles(): array
{
return Cache::getList(
self::cacheKeys($this->getId())['circles'],
@ -410,6 +368,24 @@ class Actor extends Entity
EOF, ['self' => $this->getId()]);
}
public function getSubscriptionsUrl(): string
{
if ($this->getIsLocal()) {
return Router::url('actor_subscriptions_nickname', ['nickname' => $this->getNickname()]);
} else {
return Router::url('actor_subscriptions_id', ['id' => $this->getId()]);
}
}
public function getSubscribersUrl(): string
{
if ($this->getIsLocal()) {
return Router::url('actor_subscribers_nickname', ['nickname' => $this->getNickname()]);
} else {
return Router::url('actor_subscribers_id', ['id' => $this->getId()]);
}
}
/**
* Resolve an ambiguous nickname reference, checking in following order:
* - Actors that $sender subscribes to
@ -428,9 +404,9 @@ class Actor extends Entity
self::cacheKeys($this->getId(), $nickname)['relative-nickname'],
fn () => DB::dql(
<<<'EOF'
select a from actor a where
a.id in (select fa.subscribed_id from subscription fa join actor aa with fa.subscribed = aa.id where fa.subscriber = :actor_id and aa.nickname = :nickname) or
a.id in (select fb.subscriber_id from subscription fb join actor ab with fb.subscriber = ab.id where fb.subscribed = :actor_id and ab.nickname = :nickname) or
SELECT a FROM actor AS a WHERE
a.id IN (SELECT sa.subscribed_id FROM subscription sa JOIN actor aa WITH sa.subscribed_id = aa.id WHERE sa.subscriber_id = :actor_id AND aa.nickname = :nickname) OR
a.id IN (SELECT sb.subscriber_id FROM subscription sb JOIN actor ab WITH sb.subscriber_id = ab.id WHERE sb.subscribed_id = :actor_id AND ab.nickname = :nickname) OR
a.nickname = :nickname
EOF,
['nickname' => $nickname, 'actor_id' => $this->getId()],