| @@ -97,6 +97,8 @@ class PostsController < ApplicationController | |||||
| tags = Tag.normalise_tags(tag_names) | tags = Tag.normalise_tags(tag_names) | ||||
| tags = Tag.expand_parent_tags(tags) | tags = Tag.expand_parent_tags(tags) | ||||
| sync_post_tags!(post, tags) | sync_post_tags!(post, tags) | ||||
| post.reload | |||||
| render json: post.as_json(include: { tags: { only: [:id, :category, :post_count], | render json: post.as_json(include: { tags: { only: [:id, :category, :post_count], | ||||
| methods: [:name, :has_wiki] } }), | methods: [:name, :has_wiki] } }), | ||||
| status: :created | status: :created | ||||
| @@ -136,6 +138,8 @@ class PostsController < ApplicationController | |||||
| Tag.normalise_tags(tag_names, with_tagme: false) | Tag.normalise_tags(tag_names, with_tagme: false) | ||||
| tags = Tag.expand_parent_tags(tags) | tags = Tag.expand_parent_tags(tags) | ||||
| sync_post_tags!(post, tags) | sync_post_tags!(post, tags) | ||||
| post.reload | |||||
| json = post.as_json | json = post.as_json | ||||
| json['tags'] = build_tag_tree_for(post.tags) | json['tags'] = build_tag_tree_for(post.tags) | ||||
| render json:, status: :ok | render json:, status: :ok | ||||
| @@ -197,6 +201,8 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def filter_posts_by_tags tag_names, match_type | def filter_posts_by_tags tag_names, match_type | ||||
| tag_names = TagName.canonicalise(tag_names) | |||||
| posts = Post.joins(tags: :tag_name) | posts = Post.joins(tags: :tag_name) | ||||
| if match_type == 'any' | if match_type == 'any' | ||||
| @@ -16,12 +16,43 @@ class TagsController < ApplicationController | |||||
| q = params[:q].to_s.strip | q = params[:q].to_s.strip | ||||
| return render json: [] if q.blank? | return render json: [] if q.blank? | ||||
| tags = (Tag.joins(:tag_name).includes(:tag_name) | |||||
| .where('(tags.category = ? AND tag_names.name LIKE ?) OR tag_names.name LIKE ?', | |||||
| 'nico', "nico:#{ q }%", "#{ q }%") | |||||
| .order(Arel.sql('post_count DESC, tag_names.name ASC')) | |||||
| .limit(20)) | |||||
| render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | |||||
| with_nico = !(params[:nico].to_s.strip.downcase.in?(['0', 'false', 'off', 'no'])) | |||||
| alias_rows = | |||||
| TagName | |||||
| .where('name LIKE ?', "#{ q }%") | |||||
| .where.not(canonical_id: nil) | |||||
| .pluck(:canonical_id, :name) | |||||
| matched_alias_by_tag_name_id = { } | |||||
| canonical_ids = [] | |||||
| alias_rows.each do |canonical_id, alias_name| | |||||
| canonical_ids << canonical_id | |||||
| matched_alias_by_tag_name_id[canonical_id] ||= alias_name | |||||
| end | |||||
| base = Tag.joins(:tag_name).includes(:tag_name) | |||||
| canonical_hit = | |||||
| base | |||||
| .where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') + | |||||
| 'tag_names.name LIKE ?'), | |||||
| *(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%") | |||||
| tags = | |||||
| if canonical_ids.present? | |||||
| canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) | |||||
| else | |||||
| canonical_hit | |||||
| end | |||||
| tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a | |||||
| render json: tags.map { |tag| | |||||
| tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | |||||
| .merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id]) | |||||
| } | |||||
| end | end | ||||
| def show | def show | ||||
| @@ -8,8 +8,10 @@ class Post < ApplicationRecord | |||||
| has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post | has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post | ||||
| has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | ||||
| has_many :tags, through: :active_post_tags | has_many :tags, through: :active_post_tags | ||||
| has_many :user_post_views, dependent: :destroy | |||||
| has_many :post_similarities | |||||
| has_many :user_post_views, dependent: :delete_all | |||||
| has_many :post_similarities, dependent: :delete_all | |||||
| has_one_attached :thumbnail | has_one_attached :thumbnail | ||||
| before_validation :normalise_url | before_validation :normalise_url | ||||
| @@ -3,53 +3,53 @@ class Tag < ApplicationRecord | |||||
| ; | ; | ||||
| end | end | ||||
| has_many :post_tags, dependent: :delete_all, inverse_of: :tag | |||||
| has_many :post_tags, inverse_of: :tag | |||||
| has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag | has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag | ||||
| has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | ||||
| has_many :posts, through: :active_post_tags | has_many :posts, through: :active_post_tags | ||||
| has_many :tag_aliases, dependent: :destroy | |||||
| has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy | has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy | ||||
| has_many :linked_tags, through: :nico_tag_relations, source: :tag | has_many :linked_tags, through: :nico_tag_relations, source: :tag | ||||
| has_many :reversed_nico_tag_relations, class_name: 'NicoTagRelation', | |||||
| foreign_key: :tag_id, | |||||
| dependent: :destroy | |||||
| has_many :reversed_nico_tag_relations, | |||||
| class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy | |||||
| has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag | has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag | ||||
| has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy | has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy | ||||
| has_many :children, through: :tag_implications, source: :tag | has_many :children, through: :tag_implications, source: :tag | ||||
| has_many :reversed_tag_implications, class_name: 'TagImplication', | |||||
| foreign_key: :tag_id, | |||||
| dependent: :destroy | |||||
| has_many :reversed_tag_implications, | |||||
| class_name: 'TagImplication', foreign_key: :tag_id, dependent: :destroy | |||||
| has_many :parents, through: :reversed_tag_implications, source: :parent_tag | has_many :parents, through: :reversed_tag_implications, source: :parent_tag | ||||
| has_many :tag_similarities, dependent: :delete_all | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| delegate :name, to: :tag_name, allow_nil: true | delegate :name, to: :tag_name, allow_nil: true | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| enum :category, { deerjikist: 'deerjikist', | |||||
| meme: 'meme', | |||||
| character: 'character', | |||||
| general: 'general', | |||||
| material: 'material', | |||||
| nico: 'nico', | |||||
| meta: 'meta' } | |||||
| enum :category, deerjikist: 'deerjikist', | |||||
| meme: 'meme', | |||||
| character: 'character', | |||||
| general: 'general', | |||||
| material: 'material', | |||||
| nico: 'nico', | |||||
| meta: 'meta' | |||||
| validates :category, presence: true, inclusion: { in: Tag.categories.keys } | validates :category, presence: true, inclusion: { in: Tag.categories.keys } | ||||
| validate :nico_tag_name_must_start_with_nico | validate :nico_tag_name_must_start_with_nico | ||||
| validate :tag_name_must_be_canonical | |||||
| scope :nico_tags, -> { where(category: :nico) } | scope :nico_tags, -> { where(category: :nico) } | ||||
| CATEGORY_PREFIXES = { | CATEGORY_PREFIXES = { | ||||
| 'gen:' => 'general', | |||||
| 'djk:' => 'deerjikist', | |||||
| 'meme:' => 'meme', | |||||
| 'chr:' => 'character', | |||||
| 'mtr:' => 'material', | |||||
| 'meta:' => 'meta' }.freeze | |||||
| 'gen:' => :general, | |||||
| 'djk:' => :deerjikist, | |||||
| 'meme:' => :meme, | |||||
| 'chr:' => :character, | |||||
| 'mtr:' => :material, | |||||
| 'meta:' => :meta }.freeze | |||||
| def name= val | def name= val | ||||
| (self.tag_name ||= build_tag_name).name = val | (self.tag_name ||= build_tag_name).name = val | ||||
| @@ -60,11 +60,11 @@ class Tag < ApplicationRecord | |||||
| end | end | ||||
| def self.tagme | def self.tagme | ||||
| @tagme ||= find_or_create_by_tag_name!('タグ希望', category: 'meta') | |||||
| @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) | |||||
| end | end | ||||
| def self.bot | def self.bot | ||||
| @bot ||= find_or_create_by_tag_name!('bot操作', category: 'meta') | |||||
| @bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) | |||||
| end | end | ||||
| def self.normalise_tags tag_names, with_tagme: true, deny_nico: true | def self.normalise_tags tag_names, with_tagme: true, deny_nico: true | ||||
| @@ -74,8 +74,8 @@ class Tag < ApplicationRecord | |||||
| tags = tag_names.map do |name| | tags = tag_names.map do |name| | ||||
| pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] | pf, cat = CATEGORY_PREFIXES.find { |p, _| name.start_with?(p) } || ['', nil] | ||||
| name = name.delete_prefix(pf) | |||||
| find_or_create_by_tag_name!(name, category: (cat || 'general')).tap do |tag| | |||||
| name = TagName.canonicalise(name.delete_prefix(pf)).first | |||||
| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| | |||||
| if cat && tag.category != cat | if cat && tag.category != cat | ||||
| tag.update!(category: cat) | tag.update!(category: cat) | ||||
| end | end | ||||
| @@ -111,6 +111,8 @@ class Tag < ApplicationRecord | |||||
| def self.find_or_create_by_tag_name! name, category: | def self.find_or_create_by_tag_name! name, category: | ||||
| tn = TagName.find_or_create_by!(name: name.to_s.strip) | tn = TagName.find_or_create_by!(name: name.to_s.strip) | ||||
| tn = tn.canonical if tn.canonical_id? | |||||
| Tag.find_or_create_by!(tag_name_id: tn.id) do |t| | Tag.find_or_create_by!(tag_name_id: tn.id) do |t| | ||||
| t.category = category | t.category = category | ||||
| end | end | ||||
| @@ -127,4 +129,10 @@ class Tag < ApplicationRecord | |||||
| errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' | errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' | ||||
| end | end | ||||
| end | end | ||||
| def tag_name_must_be_canonical | |||||
| if tag_name&.canonical_id? | |||||
| errors.add :tag_name, 'tag_names へは実体を示す必要があります.' | |||||
| end | |||||
| end | |||||
| end | end | ||||
| @@ -1,6 +0,0 @@ | |||||
| class TagAlias < ApplicationRecord | |||||
| belongs_to :tag | |||||
| validates :tag_id, presence: true | |||||
| validates :name, presence: true, length: { maximum: 255 }, uniqueness: true | |||||
| end | |||||
| @@ -6,4 +6,37 @@ class TagName < ApplicationRecord | |||||
| has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id | has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id | ||||
| validates :name, presence: true, length: { maximum: 255 }, uniqueness: true | validates :name, presence: true, length: { maximum: 255 }, uniqueness: true | ||||
| validate :canonical_must_be_canonical | |||||
| validate :alias_name_must_not_have_prefix | |||||
| validate :canonical_must_not_be_present_with_tag_or_wiki_page | |||||
| def self.canonicalise names | |||||
| names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) | |||||
| return [] if names.blank? | |||||
| tns = TagName.includes(:canonical).where(name: names).index_by(&:name) | |||||
| names.map { |name| tns[name]&.canonical&.name || name }.uniq | |||||
| end | |||||
| private | |||||
| def canonical_must_be_canonical | |||||
| if canonical&.canonical_id? | |||||
| errors.add :canonical, 'canonical は実体を示す必要があります.' | |||||
| end | |||||
| end | |||||
| def alias_name_must_not_have_prefix | |||||
| if canonical_id? && name.to_s.include?(':') | |||||
| errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' | |||||
| end | |||||
| end | |||||
| def canonical_must_not_be_present_with_tag_or_wiki_page | |||||
| if canonical_id? && (tag || wiki_page) | |||||
| errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.' | |||||
| end | |||||
| end | |||||
| end | end | ||||
| @@ -30,6 +30,7 @@ RSpec.describe 'Posts API', type: :request do | |||||
| let!(:tag) { Tag.create!(tag_name:, category: "general") } | let!(:tag) { Tag.create!(tag_name:, category: "general") } | ||||
| let!(:tag_name2) { TagName.create!(name: 'unko') } | let!(:tag_name2) { TagName.create!(name: 'unko') } | ||||
| let!(:tag2) { Tag.create!(tag_name: tag_name2, category: 'deerjikist') } | let!(:tag2) { Tag.create!(tag_name: tag_name2, category: 'deerjikist') } | ||||
| let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } | |||||
| let!(:hit_post) do | let!(:hit_post) do | ||||
| Post.create!(uploaded_user: user, title: "hello spec world", | Post.create!(uploaded_user: user, title: "hello spec world", | ||||
| @@ -86,6 +87,25 @@ RSpec.describe 'Posts API', type: :request do | |||||
| end | end | ||||
| end | end | ||||
| it "filters posts by q (hit case by alias)" do | |||||
| get "/posts", params: { tags: "manko" } | |||||
| expect(response).to have_http_status(:ok) | |||||
| posts = json.fetch('posts') | |||||
| ids = posts.map { |p| p['id'] } | |||||
| expect(ids).to include(hit_post.id) | |||||
| expect(ids).not_to include(miss_post.id) | |||||
| expect(json['count']).to be_an(Integer) | |||||
| posts.each do |p| | |||||
| expect(p['tags']).to be_an(Array) | |||||
| p['tags'].each do |t| | |||||
| expect(t).to include('name', 'category', 'has_wiki') | |||||
| end | |||||
| end | |||||
| end | |||||
| it "returns empty posts when nothing matches" do | it "returns empty posts when nothing matches" do | ||||
| get "/posts", params: { tags: "no_such_keyword_12345" } | get "/posts", params: { tags: "no_such_keyword_12345" } | ||||
| @@ -135,6 +155,7 @@ RSpec.describe 'Posts API', type: :request do | |||||
| describe 'POST /posts' do | describe 'POST /posts' do | ||||
| let(:member) { create(:user, :member) } | let(:member) { create(:user, :member) } | ||||
| let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } | |||||
| it '401 when not logged in' do | it '401 when not logged in' do | ||||
| sign_out | sign_out | ||||
| @@ -167,6 +188,25 @@ RSpec.describe 'Posts API', type: :request do | |||||
| expect(json['tags'][0]).to have_key('name') | expect(json['tags'][0]).to have_key('name') | ||||
| end | end | ||||
| it '201 and creates post + tags when member and tags have aliases' do | |||||
| sign_in_as(member) | |||||
| post '/posts', params: { | |||||
| title: 'new post', | |||||
| url: 'https://example.com/new', | |||||
| tags: 'manko', # 既存タグ名を投げる | |||||
| thumbnail: dummy_upload | |||||
| } | |||||
| expect(response).to have_http_status(:created) | |||||
| expect(json).to include('id', 'title', 'url') | |||||
| # tags が name を含むこと(API 側の serialization が正しいこと) | |||||
| names = json.fetch('tags').map { |t| t['name'] } | |||||
| expect(names).to include('spec_tag') | |||||
| expect(names).not_to include('manko') | |||||
| end | |||||
| context "when nico tag already exists in tags" do | context "when nico tag already exists in tags" do | ||||
| before do | before do | ||||
| Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'), | Tag.find_or_create_by!(tag_name: TagName.find_or_create_by!(name: 'nico:nico_tag'), | ||||
| @@ -5,6 +5,7 @@ require 'rails_helper' | |||||
| RSpec.describe 'Tags API', type: :request do | RSpec.describe 'Tags API', type: :request do | ||||
| let!(:tn) { TagName.create!(name: 'spec_tag') } | let!(:tn) { TagName.create!(name: 'spec_tag') } | ||||
| let!(:tag) { Tag.create!(tag_name: tn, category: 'general') } | let!(:tag) { Tag.create!(tag_name: tn, category: 'general') } | ||||
| let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) } | |||||
| describe 'GET /tags' do | describe 'GET /tags' do | ||||
| it 'returns tags with name' do | it 'returns tags with name' do | ||||
| @@ -56,6 +57,20 @@ RSpec.describe 'Tags API', type: :request do | |||||
| expect(json).to be_an(Array) | expect(json).to be_an(Array) | ||||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | expect(json.map { |t| t['name'] }).to include('spec_tag') | ||||
| t = json.find { |t| t['name'] == 'spec_tag' } | |||||
| expect(t).to have_key('matched_alias') | |||||
| expect(t['matched_alias']).to be(nil) | |||||
| end | |||||
| it 'returns matching canonical tags by q with aliases' do | |||||
| get '/tags/autocomplete', params: { q: 'unk' } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(json).to be_an(Array) | |||||
| expect(json.map { |t| t['name'] }).to include('spec_tag') | |||||
| t = json.find { |t| t['name'] == 'spec_tag' } | |||||
| expect(t['matched_alias']).to eq('unko') | |||||
| end | end | ||||
| end | end | ||||