diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 85ef6d2..ba94dce 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -166,7 +166,7 @@ class TagsController < ApplicationController rows = (internal_rows + external_rows) - .sort_by { |row| [-row[:post_count], row[:name]] } + .sort_by { |row| [-row['post_count'], row['name']] } .first(20) render json: rows diff --git a/backend/app/models/external_tag.rb b/backend/app/models/external_tag.rb index 305c641..f49ec54 100644 --- a/backend/app/models/external_tag.rb +++ b/backend/app/models/external_tag.rb @@ -6,8 +6,13 @@ class ExternalTag < ApplicationRecord has_many :posts, through: :post_external_tags has_many :nico_tag_versions, foreign_key: :tag_id, inverse_of: :external_tag - has_many :nico_tag_relations, foreign_key: :tag_id, dependent: :destroy - has_many :linked_tags, through: :nico_tag_relations, source: :nico_tag + + has_many :nico_tag_relations, + foreign_key: :nico_tag_id, + inverse_of: :nico_tag, + dependent: :destroy + + has_many :linked_tags, through: :nico_tag_relations, source: :tag def snapshot_linked_tag_names linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') diff --git a/backend/app/models/nico_tag_relation.rb b/backend/app/models/nico_tag_relation.rb index d2c4a82..ccaa4d2 100644 --- a/backend/app/models/nico_tag_relation.rb +++ b/backend/app/models/nico_tag_relation.rb @@ -1,6 +1,9 @@ class NicoTagRelation < ApplicationRecord - belongs_to :nico_tag, class_name: 'Tag' - belongs_to :tag, class_name: 'Tag' + belongs_to :nico_tag, + class_name: 'ExternalTag', + foreign_key: :nico_tag_id, + inverse_of: :nico_tag_relations + belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id validates :nico_tag_id, presence: true validates :tag_id, presence: true diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 1e37685..eabe5ba 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -28,8 +28,11 @@ class Tag < ApplicationRecord has_many :post_tags, inverse_of: :tag has_many :posts, through: :post_tags - has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy - has_many :linked_tags, through: :nico_tag_relations, source: :tag + has_many :reversed_nico_tag_relations, + class_name: 'NicoTagRelation', + foreign_key: :nico_tag_id, + dependent: :destroy + 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 :children, through: :tag_implications, source: :tag diff --git a/backend/app/services/nico_tag_version_recorder.rb b/backend/app/services/nico_tag_version_recorder.rb index 34dfbde..149fb17 100644 --- a/backend/app/services/nico_tag_version_recorder.rb +++ b/backend/app/services/nico_tag_version_recorder.rb @@ -1,20 +1,22 @@ class NicoTagVersionRecorder < VersionRecorder - def self.record! tag:, event_type:, created_by_user: - new(tag:, event_type:, created_by_user:).record! + def self.record! external_tag:, event_type:, created_by_user: + new(external_tag:, event_type:, created_by_user:).record! end - def initialize tag:, event_type:, created_by_user: - super(record: tag, event_type:, created_by_user:) + def initialize external_tag:, event_type:, created_by_user: + super(record: external_tag, event_type:, created_by_user:) end private def version_class = NicoTagVersion def version_association = :nico_tag_versions - def record_key = :tag + def record_key = :external_tag def snapshot_attributes { name: "#{ @record.platform }:#{ @record.name }", linked_tags: @record.snapshot_linked_tag_names.join(' ') } end + + def tracks_version_no_on_record? = false end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index e289b61..32ab7ca 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -47,10 +47,14 @@ class VersionRecorder end def update_record_version_no! version_no + return unless tracks_version_no_on_record? + @record.update_columns(version_no:) @record.version_no = version_no end + def tracks_version_no_on_record? = true + def validate_version_sequence! latest if !(latest) && @event_type != 'create' raise "#{ version_class.name } first event must be create" @@ -60,7 +64,7 @@ class VersionRecorder raise "#{ version_class.name } create event already exists" end - return unless latest + return if !(latest) || !(tracks_version_no_on_record?) if @record.version_no != latest.version_no raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 2331f5b..11efcde 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -30,9 +30,28 @@ namespace :nico do end end - PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt| - pt.destroy! + PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!) + end + + def sync_post_external_tags! post, desired_external_tag_ids, current_external_tag_ids: nil + current_external_tag_ids ||= + PostExternalTag.where(post_id: post.id).pluck(:external_tag_id).to_set + desired_external_tag_ids = desired_external_tag_ids.compact.to_set + + to_add = desired_external_tag_ids - current_external_tag_ids + to_remove = current_external_tag_ids - desired_external_tag_ids + + ExternalTag.where(id: to_add.to_a).find_each do |external_tag| + begin + PostExternalTag.create!(post:, external_tag:) + rescue ActiveRecord::RecordNotUnique + ; + end end + + PostExternalTag + .where(post_id: post.id, external_tag_id: to_remove.to_a) + .find_each(&:destroy!) end mysql_user = ENV['MYSQL_USER'] @@ -111,58 +130,63 @@ namespace :nico do sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) end - tags = post.tags # 既存のタグ Id. 集合 - kept_tag_ids = tags.pluck(:id).to_set - # うち内部タグ Id. 集合 - kept_non_nico_tag_ids = tags.pluck(:id).to_set + kept_tag_ids = post.tags.pluck(:id).to_set + + # 既存の外部タグ Id. 集合 + kept_external_tag_ids = post.external_tags.pluck(:id).to_set + + # 記載すべき外部タグ Id. のリスト + desired_external_tag_ids = [] - # 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト - desired_nico_tag_based_ids = [] # 記載すべき内部タグ Id. のリスト - desired_non_nico_tag_ids = [] + desired_tag_ids = kept_tag_ids.to_a datum['tags'].each do |raw| tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw) unless tag.nico_tag_versions.exists? - NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user: nil) + NicoTagVersionRecorder.record!(external_tag: tag, + event_type: :create, + created_by_user: nil) end - desired_nico_tag_based_ids << tag.id + desired_external_tag_ids << tag.id # 新たに記載される外部タグと連携される内部タグを記載 - unless tag.id.in?(kept_tag_ids) - linked_ids = tag.linked_tags.pluck(:id) - desired_non_nico_tag_ids.concat(linked_ids) - desired_nico_tag_based_ids.concat(linked_ids) + # 連携タグは記載すれども消除せず. + unless tag.id.in?(kept_external_tag_ids) + desired_tag_ids.concat(tag.linked_tags.pluck(:id)) end end deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user']) if deerjikist - desired_non_nico_tag_ids << deerjikist.tag_id - desired_nico_tag_based_ids << deerjikist.tag_id - elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?) - desired_non_nico_tag_ids << Tag.no_deerjikist.id - desired_nico_tag_based_ids << Tag.no_deerjikist.id + desired_tag_ids << deerjikist.tag_id + elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?) + desired_tag_ids << Tag.no_deerjikist.id end - desired_nico_tag_based_ids.uniq! + desired_external_tag_ids.uniq! + desired_tag_ids.uniq! - desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids - desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a) - desired_non_nico_tag_ids.uniq! - if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set - desired_all_tag_ids << Tag.bot.id + # 外部タグの記載に際しては “bot 操作” タグを記載しなぃ. + if kept_tag_ids != desired_tag_ids.to_set + desired_tag_ids << Tag.bot.id + desired_tag_ids.uniq! end - desired_all_tag_ids.uniq! - sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) + tags_changed = + kept_tag_ids != desired_tag_ids.to_set || + kept_external_tag_ids != desired_external_tag_ids.to_set + + sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids) + sync_post_external_tags!(post, desired_external_tag_ids, + current_external_tag_ids: kept_external_tag_ids) if post_created PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) - elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set + elsif post_changed || tags_changed PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 1ec5b08..542eb2d 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -378,4 +378,119 @@ RSpec.describe 'nico:sync' do expect(versions.second.title).to eq('changed title') expect(versions.second.tags).to eq(snapshot_tags(post.reload)) end + + def create_external_tag!(name) + ExternalTag.create!(platform: :nico, name:) + end + + def create_nico_sync_post! + post = Post.create!( + title: 't', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + PostTag.create!(post:, tag: Tag.no_deerjikist) + + post + end + + def run_nico_sync_with_tags! tags + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => tags, + 'user' => nil + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + run_rake_task('nico:sync') + end + + it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do + post = create_nico_sync_post! + + external_tag = create_external_tag!('AAA') + linked_tag = create_tag!('spec_linked', category: :general) + link_nico_to_tag!(external_tag, linked_tag) + + run_nico_sync_with_tags!(['AAA']) + + expect(post.reload.external_tags).to include(external_tag) + expect(post.tags).to include(linked_tag) + end + + it '外部タグに差分がない場合,内外マッピングが変はっても連携タグを再評価しない' do + post = create_nico_sync_post! + + external_tag = create_external_tag!('AAA') + old_linked_tag = create_tag!('spec_old_linked', category: :general) + new_linked_tag = create_tag!('spec_new_linked', category: :general) + + relation = link_nico_to_tag!(external_tag, old_linked_tag) + + run_nico_sync_with_tags!(['AAA']) + + expect(post.reload.tags).to include(old_linked_tag) + + # 人手で連携タグを消除する. + PostTag.find_by!(post:, tag: old_linked_tag).destroy! + + # 内外マッピングを変更する. + relation.destroy! + link_nico_to_tag!(external_tag, new_linked_tag) + + # 外部タグ自体には差分が無い. + run_nico_sync_with_tags!(['AAA']) + + post.reload + + expect(post.external_tags).to include(external_tag) + expect(post.tags).not_to include(old_linked_tag) + expect(post.tags).not_to include(new_linked_tag) + end + + it '外部タグの消除では連携タグを消除せず,再記載時にその時点の内外マッピングを適用する' do + post = create_nico_sync_post! + + external_tag = create_external_tag!('AAA') + old_linked_tag = create_tag!('spec_old_linked', category: :general) + new_linked_tag = create_tag!('spec_new_linked', category: :general) + + relation = link_nico_to_tag!(external_tag, old_linked_tag) + + # 外部タグを新規記載する. + run_nico_sync_with_tags!(['AAA']) + + post.reload + expect(post.external_tags).to include(external_tag) + expect(post.tags).to include(old_linked_tag) + + # 外部タグを消除する. + run_nico_sync_with_tags!([]) + + post.reload + expect(post.external_tags).not_to include(external_tag) + + # 外部タグの消除によって連携タグまでは消除されない. + expect(post.tags).to include(old_linked_tag) + + # 外部タグが記載されてゐない間に内外マッピングを変更する. + relation.destroy! + link_nico_to_tag!(external_tag, new_linked_tag) + + # 同じ外部タグを再記載する. + run_nico_sync_with_tags!(['AAA']) + + post.reload + + expect(post.external_tags).to include(external_tag) + + # 旧連携タグは自動的には消除されない. + expect(post.tags).to include(old_linked_tag) + + # 再記載時点の内外マッピングが新たに適用される. + expect(post.tags).to include(new_linked_tag) + end end