diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 37fa3565b..68c46d793 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -2,15 +2,12 @@ class AutocompleteController < ApplicationController respond_to :xml, :json def index - @tags = Tag.names_matches_with_aliases(params[:query], params.fetch(:limit, 10).to_i) + @query = params.dig(:search, :query) + @type = params.dig(:search, :type) + @limit = params.fetch(:limit, 10).to_i + @autocomplete = AutocompleteService.new(@query, @type, current_user: CurrentUser.user, limit: @limit) - if request.variant.opensearch? - expires_in 1.hour - results = [params[:query], @tags.map(&:pretty_name)] - respond_with(results) - else - # XXX - respond_with(@tags.map(&:attributes)) - end + @results = @autocomplete.autocomplete_results + respond_with(@results) end end diff --git a/app/javascript/src/javascripts/autocomplete.js.erb b/app/javascript/src/javascripts/autocomplete.js.erb index a1d32f478..1c81f3651 100644 --- a/app/javascript/src/javascripts/autocomplete.js.erb +++ b/app/javascript/src/javascripts/autocomplete.js.erb @@ -3,16 +3,10 @@ import CurrentUser from './current_user' let Autocomplete = {}; /* eslint-disable */ -Autocomplete.METATAGS = <%= PostQueryBuilder::METATAGS.to_json.html_safe %>; Autocomplete.TAG_CATEGORIES = <%= TagCategory.mapping.to_json.html_safe %>; -Autocomplete.ORDER_METATAGS = <%= PostQueryBuilder::ORDER_METATAGS.to_json.html_safe %>; -Autocomplete.DISAPPROVAL_REASONS = <%= PostDisapproval::REASONS.to_json.html_safe %>; /* eslint-enable */ -Autocomplete.MISC_STATUSES = ["deleted", "active", "pending", "flagged", "banned", "modqueue", "unmoderated", "appealed"]; Autocomplete.TAG_PREFIXES = "-|~|" + Object.keys(Autocomplete.TAG_CATEGORIES).map(category => category + ":").join("|"); -Autocomplete.METATAGS_REGEX = Autocomplete.METATAGS.concat(Object.keys(Autocomplete.TAG_CATEGORIES)).join("|"); -Autocomplete.TERM_REGEX = new RegExp(`([-~]*)(?:(${Autocomplete.METATAGS_REGEX}):)?(\\S*)$`, "i"); Autocomplete.MAX_RESULTS = 10; Autocomplete.initialize_all = function() { @@ -39,20 +33,20 @@ Autocomplete.initialize_all = function() { this.initialize_tag_autocomplete(); this.initialize_mention_autocomplete($("form div.input.dtext textarea")); - this.initialize_fields($('[data-autocomplete="tag"]'), Autocomplete.tag_source); - this.initialize_fields($('[data-autocomplete="artist"]'), Autocomplete.artist_source); - this.initialize_fields($('[data-autocomplete="pool"]'), Autocomplete.pool_source); - this.initialize_fields($('[data-autocomplete="user"]'), Autocomplete.user_source); - this.initialize_fields($('[data-autocomplete="wiki-page"]'), Autocomplete.wiki_source); - this.initialize_fields($('[data-autocomplete="favorite-group"]'), Autocomplete.favorite_group_source); - this.initialize_fields($('[data-autocomplete="saved-search-label"]'), Autocomplete.saved_search_source); + this.initialize_fields($('[data-autocomplete="tag"]'), "tag"); + this.initialize_fields($('[data-autocomplete="artist"]'), "artist"); + this.initialize_fields($('[data-autocomplete="pool"]'), "pool"); + this.initialize_fields($('[data-autocomplete="user"]'), "user"); + this.initialize_fields($('[data-autocomplete="wiki-page"]'), "wiki_page"); + this.initialize_fields($('[data-autocomplete="favorite-group"]'), "favorite_group"); + this.initialize_fields($('[data-autocomplete="saved-search-label"]'), "saved_search_label"); } } -Autocomplete.initialize_fields = function($fields, autocomplete) { +Autocomplete.initialize_fields = function($fields, type) { $fields.autocomplete({ source: async function(request, respond) { - let results = await autocomplete(request.term); + let results = await Autocomplete.autocomplete_source(request.term, type); respond(results); }, }); @@ -84,7 +78,7 @@ Autocomplete.initialize_mention_autocomplete = function($fields) { } if (name) { - let results = await Autocomplete.user_source(name, "@"); + let results = await Autocomplete.autocomplete_source(name, "mention"); resp(results); } } @@ -106,76 +100,18 @@ Autocomplete.initialize_tag_autocomplete = function() { return false; }, source: async function(req, resp) { - var query = Autocomplete.parse_query(req.term, this.element.get(0).selectionStart); - var metatag = query.metatag; - var term = query.term; - var results = []; - - switch (metatag) { - case "order": - case "status": - case "rating": - case "locked": - case "child": - case "parent": - case "filetype": - case "disapproved": - case "embedded": - results = Autocomplete.static_metatag_source(term, metatag); - break; - case "user": - case "approver": - case "commenter": - case "comm": - case "noter": - case "noteupdater": - case "commentaryupdater": - case "artcomm": - case "fav": - case "ordfav": - case "appealer": - case "flagger": - case "upvote": - case "downvote": - results = await Autocomplete.user_source(term, metatag + ":"); - break; - case "pool": - case "ordpool": - results = await Autocomplete.pool_source(term, metatag + ":"); - break; - case "favgroup": - case "ordfavgroup": - results = await Autocomplete.favorite_group_source(term, metatag + ":", CurrentUser.data("id")); - break; - case "search": - results = await Autocomplete.saved_search_source(term, metatag + ":"); - break; - case "tag": - results = await Autocomplete.tag_source(term); - break; - default: - results = []; - break; - } - + let term = Autocomplete.current_term(this.element); + let results = await Autocomplete.autocomplete_source(term, "tag_query"); resp(results); } }); } -Autocomplete.parse_query = function(text, caret) { - let before_caret_text = text.substring(0, caret); - let match = before_caret_text.match(Autocomplete.TERM_REGEX); - - let operator = match[1]; - let metatag = match[2] ? match[2].toLowerCase() : "tag"; - let term = match[3]; - - if (metatag in Autocomplete.TAG_CATEGORIES) { - metatag = "tag"; - } - - return { operator: operator, metatag: metatag, term: term }; +Autocomplete.current_term = function($input) { + let query = $input.get(0).value; + let caret = $input.get(0).selectionStart; + let match = query.substring(0, caret).match(/\S*/); + return match[0]; }; // Update the input field with the item currently focused in the @@ -264,166 +200,12 @@ Autocomplete.render_item = function(list, item) { return $list_item.appendTo(list); }; -Autocomplete.static_metatags = { - order: Autocomplete.ORDER_METATAGS, - status: ["any"].concat(Autocomplete.MISC_STATUSES), - rating: [ - "safe", "questionable", "explicit" - ], - locked: [ - "rating", "note", "status" - ], - embedded: [ - "true", "false" - ], - child: ["any", "none"].concat(Autocomplete.MISC_STATUSES), - parent: ["any", "none"].concat(Autocomplete.MISC_STATUSES), - filetype: [ - "jpg", "png", "gif", "swf", "zip", "webm", "mp4" - ], - commentary: [ - "true", "false", "translated", "untranslated" - ], - disapproved: Autocomplete.DISAPPROVAL_REASONS -} - -Autocomplete.static_metatag_source = function(term, metatag) { - var sub_metatags = this.static_metatags[metatag]; - - var matches = sub_metatags.filter(sub_metatag => sub_metatag.startsWith(term.toLowerCase())); - matches = matches.map(sub_metatag => `${metatag}:${sub_metatag}`).sort().slice(0, Autocomplete.MAX_RESULTS); - - return matches; -} - -Autocomplete.tag_source = async function(term) { - if (term === "") { - return []; - } - - let tags = await $.getJSON("/tags/autocomplete", { - "search[name_matches]": term, - "limit": Autocomplete.MAX_RESULTS, - "expiry": 7 - }); - - return tags.map(function(tag) { - return { - type: "tag", - label: tag.name.replace(/_/g, " "), - antecedent: tag.antecedent_name, - value: tag.name, - category: tag.category, - source: tag.source, - weight: tag.weight, - post_count: tag.post_count - }; - }); -} - -Autocomplete.artist_source = async function(term) { - let artists = await $.getJSON("/artists", { - "search[name_like]": term.trim().replace(/\s+/g, "_") + "*", - "search[is_deleted]": false, - "search[order]": "post_count", - "limit": Autocomplete.MAX_RESULTS, - "expiry": 7 - }); - - return artists.map(function(artist) { - return { - type: "tag", - label: artist.name.replace(/_/g, " "), - value: artist.name, - category: Autocomplete.TAG_CATEGORIES.artist, - }; - }); -}; - -Autocomplete.wiki_source = async function(term) { - let wiki_pages = await $.getJSON("/wiki_pages", { - "search[title_normalize]": term + "*", - "search[hide_deleted]": "Yes", - "search[order]": "post_count", - "limit": Autocomplete.MAX_RESULTS, - "expiry": 7 - }); - - return wiki_pages.map(function(wiki_page) { - return { - type: "tag", - label: wiki_page.title.replace(/_/g, " "), - value: wiki_page.title, - category: wiki_page.category_name - }; - }); -}; - -Autocomplete.user_source = async function(term, prefix = "") { - let users = await $.getJSON("/users", { - "search[order]": "post_upload_count", - "search[current_user_first]": "true", - "search[name_matches]": term + "*", +Autocomplete.autocomplete_source = function(query, type) { + return $.getJSON("/autocomplete", { + "search[query]": query, + "search[type]": type, "limit": Autocomplete.MAX_RESULTS }); - - return users.map(function(user) { - return { - type: "user", - label: user.name.replace(/_/g, " "), - value: prefix + user.name, - level: user.level_string - }; - }); -}; - -Autocomplete.pool_source = async function(term, prefix = "") { - let pools = await $.getJSON("/pools", { - "search[name_matches]": term, - "search[is_deleted]": false, - "search[order]": "post_count", - "limit": Autocomplete.MAX_RESULTS - }); - - return pools.map(function(pool) { - return { - type: "pool", - label: pool.name.replace(/_/g, " "), - value: prefix + pool.name, - post_count: pool.post_count, - category: pool.category - }; - }); -}; - -Autocomplete.favorite_group_source = async function(term, prefix = "", creator_id = null) { - let favgroups = await $.getJSON("/favorite_groups", { - "search[creator_id]": creator_id, - "search[name_matches]": term, - "limit": Autocomplete.MAX_RESULTS - }); - - return favgroups.map(function(favgroup) { - return { - label: favgroup.name.replace(/_/g, " "), - value: prefix + favgroup.name, - post_count: favgroup.post_count - }; - }); -}; - -Autocomplete.saved_search_source = async function(term, prefix = "") { - let labels = await $.getJSON("/saved_searches/labels", { - "search[label]": term + "*", - "limit": Autocomplete.MAX_RESULTS - }); - - return labels.map(function(label) { - return { - label: label.replace(/_/g, " "), - value: prefix + label, - }; - }); } $(document).ready(function() { diff --git a/app/logical/autocomplete_service.rb b/app/logical/autocomplete_service.rb new file mode 100644 index 000000000..4896bf225 --- /dev/null +++ b/app/logical/autocomplete_service.rb @@ -0,0 +1,165 @@ +class AutocompleteService + POST_STATUSES = %w[active deleted pending flagged appealed banned modqueue unmoderated] + + STATIC_METATAGS = { + status: %w[any] + POST_STATUSES, + child: %w[any none] + POST_STATUSES, + parent: %w[any none] + POST_STATUSES, + rating: %w[safe questionable explicit], + locked: %w[rating note status], + embedded: %w[true false], + filetype: %w[jpg png gif swf zip webm mp4], + commentary: %w[true false translated untranslated], + disapproved: PostDisapproval::REASONS, + order: PostQueryBuilder::ORDER_METATAGS + } + + attr_reader :query, :type, :limit, :current_user + + def initialize(query, type, current_user: User.anonymous, limit: 10) + @query = query.to_s + @type = type.to_sym + @current_user = current_user + @limit = limit + end + + def autocomplete_results + case type + when :tag_query + autocomplete_tag_query(query) + when :tag + autocomplete_tag(query) + when :artist + autocomplete_artist(query) + when :wiki_page + autocomplete_wiki_page(query) + when :user + autocomplete_user(query) + when :mention + autocomplete_mention(query) + when :pool + autocomplete_pool(query) + when :favorite_group + autocomplete_favorite_group(query) + when :saved_search_label + autocomplete_saved_search_label(query) + when :opensearch + autocomplete_opensearch(query) + else + [] + end + end + + def autocomplete_tag_query(string) + term = PostQueryBuilder.new(string).terms.first + return [] if term.nil? + + case term.type + when :tag + autocomplete_tag(term.name) + when :metatag + autocomplete_metatag(term.name, term.value) + end + end + + def autocomplete_tag(string) + tags = Tag.names_matches_with_aliases(string, limit) + + 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 } + end + end + + def autocomplete_metatag(metatag, value) + results = case metatag.to_sym + when :user, :approver, :commenter, :comm, :noter, :noteupdater, :commentaryupdater, + :artcomm, :fav, :ordfav, :appealer, :flagger, :upvote, :downvote + autocomplete_user(value) + when :pool, :ordpool + autocomplete_pool(value) + when :favgroup, :ordfavgroup + autocomplete_favorite_group(value) + when :search + autocomplete_saved_search_label(value) + when *STATIC_METATAGS.keys + autocomplete_static_metatag(metatag, value) + end + + results.map do |result| + { **result, value: metatag + ":" + result[:value] } + end + end + + def autocomplete_static_metatag(metatag, value) + values = STATIC_METATAGS[metatag.to_sym] + results = values.select { |v| v.starts_with?(value) }.sort.take(limit) + + results.map do |v| + { label: metatag + ":" + v, value: v } + end + end + + def autocomplete_pool(string) + string = "*" + string + "*" unless string.include?("*") + pools = Pool.undeleted.name_matches(string).search(order: "post_count").limit(limit) + + pools.map do |pool| + { type: "pool", label: pool.pretty_name, value: pool.name, post_count: pool.post_count, category: pool.category } + end + end + + def autocomplete_favorite_group(string) + string = "*" + string + "*" unless string.include?("*") + favgroups = FavoriteGroup.visible(current_user).where(creator: current_user).name_matches(string).search(order: "post_count").limit(limit) + + favgroups.map do |favgroup| + { label: favgroup.pretty_name, value: favgroup.name, post_count: favgroup.post_count } + end + end + + def autocomplete_saved_search_label(string) + labels = SavedSearch.search_labels(current_user.id, label: string).take(limit) + + labels.map do |label| + { label: label.tr("_", " "), value: label } + end + end + + def autocomplete_artist(string) + string = string + "*" unless string.include?("*") + artists = Artist.undeleted.name_matches(string).search(order: "post_count").limit(limit) + + artists.map do |artist| + { type: "tag", label: artist.pretty_name, value: artist.name, category: Tag.categories.artist } + end + end + + def autocomplete_wiki_page(string) + string = string + "*" unless string.include?("*") + wiki_pages = WikiPage.undeleted.title_matches(string).search(order: "post_count").limit(limit) + + wiki_pages.map do |wiki_page| + { type: "tag", label: wiki_page.pretty_title, value: wiki_page.title, category: wiki_page.tag&.category } + end + end + + def autocomplete_user(string) + string = string + "*" unless string.include?("*") + users = User.search(name_matches: string, current_user_first: true, order: "post_upload_count").limit(limit) + + users.map do |user| + { type: "user", label: user.pretty_name, value: user.name, level: user.level_string } + end + end + + def autocomplete_mention(string) + autocomplete_user(string).map do |result| + { **result, value: "@" + result[:value] } + end + end + + def autocomplete_opensearch(string) + results = autocomplete_tag(string).map { |result| result[:value] } + [query, results] + end +end diff --git a/app/models/artist.rb b/app/models/artist.rb index cc604789c..abc7260bf 100644 --- a/app/models/artist.rb +++ b/app/models/artist.rb @@ -203,6 +203,10 @@ class Artist < ApplicationRecord end module SearchMethods + def name_matches(query) + where_like(:name, normalize_name(query)) + end + def any_other_name_matches(regex) where(id: Artist.from("unnest(other_names) AS other_name").where_regex("other_name", regex)) end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index e10c1312f..6f914bd25 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -33,6 +33,10 @@ class WikiPage < ApplicationRecord where(title: normalize_title(title)) end + def title_matches(title) + where_like(:title, normalize_title(title)) + end + def other_names_include(name) name = normalize_other_name(name) subquery = WikiPage.from("unnest(other_names) AS other_name").where_iequals("other_name", name) diff --git a/app/views/static/opensearch.xml.erb b/app/views/static/opensearch.xml.erb index 8652a2f9c..ce8737643 100644 --- a/app/views/static/opensearch.xml.erb +++ b/app/views/static/opensearch.xml.erb @@ -4,5 +4,5 @@ <%= Danbooru.config.app_name %> search <%= root_url %>favicon.ico - + diff --git a/test/functional/autocomplete_controller_test.rb b/test/functional/autocomplete_controller_test.rb index 51c064029..4dd62ee1a 100644 --- a/test/functional/autocomplete_controller_test.rb +++ b/test/functional/autocomplete_controller_test.rb @@ -1,6 +1,17 @@ require "test_helper" class AutocompleteControllerTest < ActionDispatch::IntegrationTest + def autocomplete(query, type) + get autocomplete_index_path(search: { query: query, type: type }), as: :json + assert_response :success + + response.parsed_body.map { |result| result["value"] } + end + + def assert_autocomplete_equals(expected_value, query, type) + assert_equal(expected_value, autocomplete(query, type)) + end + context "Autocomplete controller" do context "index action" do setup do @@ -8,9 +19,20 @@ class AutocompleteControllerTest < ActionDispatch::IntegrationTest end should "work for opensearch queries" do - get autocomplete_index_path(query: "azur", variant: "opensearch"), as: :json + get autocomplete_index_path(search: { query: "azur", type: "opensearch" }), as: :json + assert_response :success - assert_equal(["azur", ["azur lane"]], response.parsed_body) + assert_equal(["azur", ["azur_lane"]], response.parsed_body) + end + + should "work for tag queries" do + assert_autocomplete_equals(["azur_lane"], "azur", "tag_query") + assert_autocomplete_equals(["azur_lane"], "-azur", "tag_query") + assert_autocomplete_equals(["azur_lane"], "~azur", "tag_query") + assert_autocomplete_equals(["azur_lane"], "AZUR", "tag_query") + + assert_autocomplete_equals(["rating:safe"], "rating:s", "tag_query") + assert_autocomplete_equals(["rating:safe"], "-rating:s", "tag_query") end end end diff --git a/test/unit/autocomplete_service_test.rb b/test/unit/autocomplete_service_test.rb new file mode 100644 index 000000000..fdcf7aeb7 --- /dev/null +++ b/test/unit/autocomplete_service_test.rb @@ -0,0 +1,120 @@ +require 'test_helper' + +class AutocompleteServiceTest < ActiveSupport::TestCase + def autocomplete(query, type, **options) + results = AutocompleteService.new(query, type, **options).autocomplete_results + results.map { |r| r[:value] } + end + + def assert_autocomplete_includes(expected_value, query, type, **options) + assert_includes(autocomplete(query, type, **options), expected_value) + end + + def assert_autocomplete_equals(expected_value, query, type, **options) + assert_equal(expected_value, autocomplete(query, type, **options)) + end + + context "#autocomplete method" do + should "autocomplete artists" do + create(:artist, name: "bkub") + assert_autocomplete_includes("bkub", "bk", :artist) + end + + should "autocomplete wiki pages" do + create(:wiki_page, title: "help:home") + assert_autocomplete_includes("help:home", "help", :wiki_page) + end + + should "autocomplete users" do + @user = create(:user, name: "fumimi") + + as(@user) do + assert_autocomplete_includes("fumimi", "fu", :user) + assert_autocomplete_includes("@fumimi", "fu", :mention) + assert_autocomplete_includes("user:fumimi", "user:fu", :tag_query) + end + end + + should "autocomplete pools" do + as(create(:user)) do + create(:pool, name: "Disgustingly_Adorable") + end + + assert_autocomplete_includes("Disgustingly_Adorable", "disgust", :pool) + assert_autocomplete_includes("pool:Disgustingly_Adorable", "pool:disgust", :tag_query) + assert_autocomplete_includes("pool:Disgustingly_Adorable", "-pool:disgust", :tag_query) + end + + should "autocomplete favorite groups" do + user = create(:user) + create(:favorite_group, name: "Stuff", creator: user) + + assert_autocomplete_equals(["Stuff"], "stu", :favorite_group, current_user: user) + assert_autocomplete_equals([], "stu", :favorite_group, current_user: User.anonymous) + + assert_autocomplete_equals(["favgroup:Stuff"], "favgroup:stu", :tag_query, current_user: user) + assert_autocomplete_equals([], "favgroup:stu", :tag_query, current_user: User.anonymous) + end + + should "autocomplete saved search labels" do + user = create(:user) + create(:saved_search, query: "bkub", labels: ["artists"], user: user) + + assert_autocomplete_equals(["artists"], "art", :saved_search_label, current_user: user) + + assert_autocomplete_equals(["search:artists"], "search:art", :tag_query, current_user: user) + end + + should "autocomplete single tags" do + create(:tag, name: "touhou") + assert_autocomplete_includes("touhou", "tou", :tag) + end + + context "for a tag search" do + should "autocomplete tags" do + create(:tag, name: "touhou") + + assert_autocomplete_includes("touhou", "tou", :tag_query) + assert_autocomplete_includes("touhou", "TOU", :tag_query) + assert_autocomplete_includes("touhou", "-tou", :tag_query) + assert_autocomplete_includes("touhou", "~tou", :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) + assert_autocomplete_equals(["child:active"], "child:act", :tag_query) + + assert_autocomplete_equals(["rating:safe"], "rating:s", :tag_query) + assert_autocomplete_equals(["rating:questionable"], "rating:q", :tag_query) + assert_autocomplete_equals(["rating:explicit"], "rating:e", :tag_query) + + assert_autocomplete_equals(["locked:rating"], "locked:r", :tag_query) + assert_autocomplete_equals(["locked:status"], "locked:s", :tag_query) + assert_autocomplete_equals(["locked:note"], "locked:n", :tag_query) + + assert_autocomplete_equals(["embedded:true"], "embedded:t", :tag_query) + assert_autocomplete_equals(["embedded:false"], "embedded:f", :tag_query) + + assert_autocomplete_equals(["filetype:jpg"], "filetype:j", :tag_query) + assert_autocomplete_equals(["filetype:png"], "filetype:p", :tag_query) + assert_autocomplete_equals(["filetype:gif"], "filetype:g", :tag_query) + assert_autocomplete_equals(["filetype:swf"], "filetype:s", :tag_query) + assert_autocomplete_equals(["filetype:zip"], "filetype:z", :tag_query) + assert_autocomplete_equals(["filetype:webm"], "filetype:w", :tag_query) + assert_autocomplete_equals(["filetype:mp4"], "filetype:m", :tag_query) + + assert_autocomplete_equals(["commentary:true"], "commentary:tru", :tag_query) + assert_autocomplete_equals(["commentary:false"], "commentary:fal", :tag_query) + assert_autocomplete_equals(["commentary:translated"], "commentary:trans", :tag_query) + assert_autocomplete_equals(["commentary:untranslated"], "commentary:untrans", :tag_query) + + assert_autocomplete_equals(["disapproved:breaks_rules"], "disapproved:break", :tag_query) + assert_autocomplete_equals(["disapproved:poor_quality"], "disapproved:poor", :tag_query) + assert_autocomplete_equals(["disapproved:disinterest"], "disapproved:dis", :tag_query) + + assert_autocomplete_equals(["order:score", "order:score_asc"], "order:sco", :tag_query) + end + end + end +end