From b0be8ae4562644469cd481ae3e8a075e040c4bc4 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 13 Dec 2020 00:45:22 -0600 Subject: [PATCH] autocomplete: rework tag autocomplete behavior. Reworks tag autocomplete to work the same way for all users. Previously autocomplete for Builders worked differently than autocomplete for regular users. This is how it works now: * If the search starts with a slash (/), then do a tag abbreviation match. For example, `/evth` matches eyebrows_visible_through_hair. * Otherwise if the search contains a wildcard (*), then just do a simple wildcard search. * Otherwise do a tag prefix match against tags and aliases. For example, `black` matches all tags or aliases beginning with `black`. * If the tag prefix match returns no results, then do a autocorrect match. The differences for regular users: * You can abbreviate tags with a slash (/). The differences for Builders: * Now tag abbreviations have to start with a slash (/). * Autocorrect isn't performed unless a regular search returns no results. * Results are always sorted by tag count. Before different types of results (regular tag matches, alias matches, abbreviation matches, and autocorrect matches) were all mixed together based on a tag weighting scheme. --- app/helpers/tags_helper.rb | 8 ------ app/logical/autocomplete_service.rb | 39 ++++++++++++++++++++++++-- app/models/tag.rb | 21 +++++++++++++- app/views/tags/index.html.erb | 2 +- test/unit/autocomplete_service_test.rb | 31 ++++++++++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index 53f0e01e7..d6e8a9728 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -3,12 +3,4 @@ module TagsHelper return nil if tag.blank? "tag-type-#{tag.category}" end - - def tag_alias_for_pattern(tag, pattern) - return nil if pattern.blank? - - tag.consequent_aliases.find do |tag_alias| - !tag.name.ilike?(pattern) && tag_alias.antecedent_name.ilike?(pattern) - end - end end diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 4896bf225..d05cb6696 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -63,10 +63,45 @@ class AutocompleteService end def autocomplete_tag(string) - tags = Tag.names_matches_with_aliases(string, limit) + if string.starts_with?("/") + string = string + "*" unless string.include?("*") + results = tag_matches(string) + results += tag_abbreviation_matches(string) + results = results.uniq.sort_by { |r| [r[:antecedent].length, -r[:post_count]] }.take(limit) + elsif string.include?("*") + results = tag_matches(string) + else + results = tag_matches(string + "*") + results = tag_autocorrect_matches(string) if results.blank? + end + + results + end + + def tag_matches(string) + name_matches = Tag.nonempty.name_matches(string).order(post_count: :desc).limit(limit) + alias_matches = Tag.nonempty.alias_matches(string).order(post_count: :desc).limit(limit) + union = "((#{name_matches.to_sql}) UNION (#{alias_matches.to_sql})) AS tags" + tags = Tag.from(union).order(post_count: :desc).limit(limit).includes(:consequent_aliases) tags.map do |tag| - { type: "tag", label: tag.name.tr("_", " "), value: tag.name, antecedent: tag.antecedent_name, category: tag.category, post_count: tag.post_count, source: nil, weight: nil } + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: tag.tag_alias_for_pattern(string)&.antecedent_name } + end + end + + def tag_abbreviation_matches(string) + tags = Tag.nonempty.abbreviation_matches(string).order(post_count: :desc).limit(limit) + + tags.map do |tag| + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: "/" + tag.abbreviation } + end + end + + def tag_autocorrect_matches(string) + tags = Tag.nonempty.fuzzy_name_matches(string).order_similarity(string).limit(limit) + + tags.map do |tag| + { type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count } end end diff --git a/app/models/tag.rb b/app/models/tag.rb index c9ca52da9..899718add 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,4 +1,6 @@ class Tag < ApplicationRecord + ABBREVIATION_REGEXP = /([a-z0-9])[a-z0-9']*($|[^a-z0-9']+)/ + has_one :wiki_page, :foreign_key => "title", :primary_key => "name" has_one :artist, :foreign_key => "name", :primary_key => "name" has_one :antecedent_alias, -> {active}, :class_name => "TagAlias", :foreign_key => "antecedent_name", :primary_key => "name" @@ -249,7 +251,7 @@ class Tag < ApplicationRecord end def alias_matches(name) - where(name: TagAlias.active.where_ilike(:antecedent_name, normalize_name(name)).select(:consequent_name)) + where(name: TagAlias.active.where_like(:antecedent_name, normalize_name(name)).select(:consequent_name)) end def name_or_alias_matches(name) @@ -260,6 +262,11 @@ class Tag < ApplicationRecord nonempty.name_matches(tag).order(post_count: :desc, name: :asc).limit(limit).pluck(:name) end + def abbreviation_matches(abbrev) + abbrev = abbrev.delete_prefix("/") + where("regexp_replace(tags.name, ?, '\\1', 'g') LIKE ?", ABBREVIATION_REGEXP.source, abbrev.to_escaped_for_sql_like) + end + def search(params) q = super @@ -352,6 +359,18 @@ class Tag < ApplicationRecord Post.system_tag_match(name) end + def abbreviation + name.gsub(ABBREVIATION_REGEXP, "\\1") + end + + def tag_alias_for_pattern(pattern) + return nil if pattern.blank? + + consequent_aliases.find do |tag_alias| + !name.ilike?(pattern) && tag_alias.antecedent_name.ilike?(pattern) + end + end + def self.model_restriction(table) super.where(table[:post_count].gt(0)) end diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb index 8648b1466..d884a56cb 100644 --- a/app/views/tags/index.html.erb +++ b/app/views/tags/index.html.erb @@ -10,7 +10,7 @@ <%= link_to_wiki "?", tag.name, class: tag_class(tag) %> <%= link_to tag.name, posts_path(tags: tag.name), class: tag_class(tag) %> - <% tag_alias = tag_alias_for_pattern(tag, params[:search][:name_or_alias_matches]) %> + <% tag_alias = tag.tag_alias_for_pattern(params[:search][:name_or_alias_matches]) %> <% if tag_alias.present? %> ← <%= link_to tag_alias.antecedent_name, tag_alias, class: "fineprint" %> <% end %> diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index fdcf7aeb7..8379bb295 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -80,6 +80,37 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("touhou", "~tou", :tag_query) end + should "autocomplete tag abbreviations" do + create(:tag, name: "mole", post_count: 150) + create(:tag, name: "mole_under_eye", post_count: 100) + create(:tag, name: "mole_under_mouth", post_count: 50) + + assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "/m", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "/mu", :tag_query) + assert_autocomplete_equals(%w[mole_under_mouth], "/mum", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "/mue", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "/*ue", :tag_query) + + assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) + assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) + end + + should "autocomplete wildcard searches" do + create(:tag, name: "mole", post_count: 150) + create(:tag, name: "mole_under_eye", post_count: 100) + create(:tag, name: "mole_under_mouth", post_count: 50) + + assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "mole*", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "*under*", :tag_query) + assert_autocomplete_equals(%w[mole_under_eye], "*eye", :tag_query) + end + + should "autocorrect misspelled tags" do + create(:tag, name: "touhou") + + assert_autocomplete_equals(%w[touhou], "touhuo", :tag_query) + end + should "autocomplete static metatags" do assert_autocomplete_equals(["status:active"], "status:act", :tag_query) assert_autocomplete_equals(["parent:active"], "parent:act", :tag_query)