From 2c1da660fd7c84717e91513bbe6967ca5dcb2b6a Mon Sep 17 00:00:00 2001 From: evazion Date: Thu, 17 Dec 2020 19:02:49 -0600 Subject: [PATCH] tags: allow tag abbreviations in searches and during tagging. Expand the tag abbreviation system introduced in b0be8ae45 so that it works in searches and when tagging posts, not just in autocomplete. For example, you can tag a post with /evth and it will add the tag eyebrows_visible_through_hair. You can search for /evth and it will search for the tag eyebrows_visible_through_hair. Some more examples: * /ops is short for one-piece_swimsuit * /hooe is short for hair_over_one_eye * /saol is short for standing_on_one_leg * /tlozbotw is short for the_legend_of_zelda:_breath_of_the_wild If two tags have the same abbreviation, then the larger tag takes precedence. For example, /be is short for blue_eyes, not brown_eyes, because blue_eyes is the bigger tag. If there is an existing shortcut alias that conflicts with the abbreviation, then the alias take precedence. For example, /sh is short for suzumiya_haruhi, not short_hair, because there's an old alias for /sh -> suzumiya_haruhi. --- app/logical/autocomplete_service.rb | 6 +++++- app/logical/concerns/searchable.rb | 2 +- app/models/tag.rb | 5 +++++ app/models/tag_alias.rb | 8 ++++++++ config/initializers/core_extensions.rb | 5 +++++ test/unit/autocomplete_service_test.rb | 8 ++++++++ test/unit/post_query_builder_test.rb | 11 +++++++++++ test/unit/post_test.rb | 27 ++++++++++++++++++++++++++ test/unit/tag_alias_test.rb | 12 ++++++++++++ 9 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb index 0c5d39f93..f75101098 100644 --- a/app/logical/autocomplete_service.rb +++ b/app/logical/autocomplete_service.rb @@ -67,9 +67,13 @@ class AutocompleteService def autocomplete_tag(string) if string.starts_with?("/") string = string + "*" unless string.include?("*") + results = tag_matches(string) results += tag_abbreviation_matches(string) - results = results.sort_by { |r| [r[:antecedent].to_s.size, -r[:post_count]] } + results = results.sort_by do |r| + [r[:type] == "tag-alias" ? 0 : 1, r[:antecedent].to_s.size, -r[:post_count]] + end + results = results.uniq { |r| r[:value] }.take(limit) elsif string.include?("*") results = tag_matches(string) diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index cac920cc8..a83a9c83a 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -53,7 +53,7 @@ module Searchable end def where_iequals(attr, value) - where_ilike(attr, value.gsub(/\\/, '\\\\').gsub(/\*/, '\*')) + where_ilike(attr, value.escape_wildcards) end # https://www.postgresql.org/docs/current/static/functions-matching.html#FUNCTIONS-POSIX-REGEXP diff --git a/app/models/tag.rb b/app/models/tag.rb index 27255bed9..527f8a797 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -270,6 +270,11 @@ class Tag < ApplicationRecord where("regexp_replace(tags.name, ?, '\\1', 'g') LIKE ?", ABBREVIATION_REGEXP.source, abbrev.to_escaped_for_sql_like) end + def find_by_abbreviation(abbrev) + abbrev = abbrev.delete_prefix("/") + abbreviation_matches(abbrev.escape_wildcards).order(post_count: :desc).first + end + def search(params) q = search_attributes(params, :id, :created_at, :updated_at, :is_locked, :category, :post_count, :name, :wiki_page, :artist, :antecedent_alias, :consequent_aliases, :antecedent_implications, :consequent_implications, :dtext_links) diff --git a/app/models/tag_alias.rb b/app/models/tag_alias.rb index 9c460e27a..b3cb9c4f2 100644 --- a/app/models/tag_alias.rb +++ b/app/models/tag_alias.rb @@ -7,7 +7,15 @@ class TagAlias < TagRelationship def self.to_aliased(names) names = Array(names).map(&:to_s) return [] if names.empty? + aliases = active.where(antecedent_name: names).map { |ta| [ta.antecedent_name, ta.consequent_name] }.to_h + + abbreviations = names.select { |name| name.starts_with?("/") && !aliases.has_key?(name) } + abbreviations.each do |abbrev| + tag = Tag.nonempty.find_by_abbreviation(abbrev) + aliases[abbrev] = tag.name if tag.present? + end + names.map { |name| aliases[name] || name } end diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb index 61f85f9d0..ef7dba48d 100644 --- a/config/initializers/core_extensions.rb +++ b/config/initializers/core_extensions.rb @@ -16,6 +16,11 @@ module Danbooru string end + # escape \ and * characters so that they're treated literally in LIKE searches. + def escape_wildcards + gsub(/\\/, '\\\\').gsub(/\*/, '\*') + end + def to_escaped_for_tsquery_split scan(/\S+/).map {|x| x.to_escaped_for_tsquery}.join(" & ") end diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb index 1abe96f80..e0463f36e 100644 --- a/test/unit/autocomplete_service_test.rb +++ b/test/unit/autocomplete_service_test.rb @@ -97,6 +97,14 @@ class AutocompleteServiceTest < ActiveSupport::TestCase assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query) assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query) end + + should "list aliases before abbreviations" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + create(:tag_alias, antecedent_name: "/hr", consequent_name: "hakurei_reimu") + + assert_autocomplete_equals(%w[hakurei_reimu hair_ribbon], "/hr", :tag_query) + end end should "autocomplete tags from wiki and artist other names" do diff --git a/test/unit/post_query_builder_test.rb b/test/unit/post_query_builder_test.rb index 193cbbad5..f4882c287 100644 --- a/test/unit/post_query_builder_test.rb +++ b/test/unit/post_query_builder_test.rb @@ -1018,6 +1018,17 @@ class PostQueryBuilderTest < ActiveSupport::TestCase assert_tag_match([post2], "-kitten") end + should "resolve abbreviations to the actual tag" do + tag1 = create(:tag, name: "hair_ribbon", post_count: 300_000) + tag2 = create(:tag, name: "hakurei_reimu", post_count: 50_000) + ta1 = create(:tag_alias, antecedent_name: "/hr", consequent_name: "hakurei_reimu") + post1 = create(:post, tag_string: "hair_ribbon") + post2 = create(:post, tag_string: "hakurei_reimu") + + assert_tag_match([post2], "/hr") + assert_tag_match([post1], "-/hr") + end + should "fail for more than 6 tags" do post1 = create(:post, rating: "s") diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index d08737624..ee470de05 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -598,6 +598,10 @@ class PostTest < ActiveSupport::TestCase context "tagged with a valid tag" do subject { @post } + setup do + create(:tag, name: "hakurei_reimu") + end + should allow_value("touhou 100%").for(:tag_string) should allow_value("touhou FOO").for(:tag_string) should allow_value("touhou -foo").for(:tag_string) @@ -618,6 +622,8 @@ class PostTest < ActiveSupport::TestCase # \u3000 = ideographic space, \u00A0 = no-break space should allow_value("touhou\u3000foo").for(:tag_string) should allow_value("touhou\u00A0foo").for(:tag_string) + + should allow_value("/hr").for(:tag_string) end context "tagged with an invalid tag" do @@ -661,6 +667,16 @@ class PostTest < ActiveSupport::TestCase end end + context "tagged with an abbreviation" do + should "expand the abbreviation" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + + @post.update!(tag_string: "aaa /hr") + assert_equal("aaa hair_ribbon", @post.reload.tag_string) + end + end + context "tagged with a metatag" do context "for typing a tag" do setup do @@ -1190,6 +1206,17 @@ class PostTest < ActiveSupport::TestCase assert_equal("aaa", @post.tag_string) end + + should "resolve abbreviations" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + + @post.update!(tag_string: "aaa hair_ribbon hakurei_reimu") + assert_equal("aaa hair_ribbon hakurei_reimu", @post.reload.tag_string) + + @post.update!(tag_string: "aaa hair_ribbon hakurei_reimu -/hr") + assert_equal("aaa hakurei_reimu", @post.reload.tag_string) + end end context "tagged with animated_gif or animated_png" do diff --git a/test/unit/tag_alias_test.rb b/test/unit/tag_alias_test.rb index 1aeb005b9..4b5a44bc8 100644 --- a/test/unit/tag_alias_test.rb +++ b/test/unit/tag_alias_test.rb @@ -79,6 +79,18 @@ class TagAliasTest < ActiveSupport::TestCase assert_equal(["bbb", "bbb"], TagAlias.to_aliased(["aaa", "aaa"])) end + should "handle abbreviations in TagAlias.to_aliased" do + create(:tag, name: "hair_ribbon", post_count: 300_000) + create(:tag, name: "hakurei_reimu", post_count: 50_000) + create(:tag, name: "kirisama_marisa", post_count: 50_000) + create(:tag, name: "kaname_madoka", post_count: 20_000) + create(:tag_alias, antecedent_name: "/hr", consequent_name: "hakurei_reimu") + + assert_equal(["hakurei_reimu"], TagAlias.to_aliased(["/hr"])) + assert_equal(["kirisama_marisa"], TagAlias.to_aliased(["/km"])) + assert_equal(["hakurei_reimu", "kirisama_marisa"], TagAlias.to_aliased(["/hr", "/km"])) + end + context "saved searches" do should "move saved searches" do @ss1 = create(:saved_search, query: "123 ... 456", user: CurrentUser.user)