コミットを比較

..
3 コミット
作成者 SHA1 メッセージ 日付
みてるぞ a0c2788e00 #419 2026-09-22 20:16:30 +09:00
みてるぞ 31fc32b377 #419 2026-09-22 19:22:16 +09:00
みてるぞ 068355720d #419 2026-09-22 15:13:14 +09:00
22個のファイルの変更660行の追加206行の削除
+13 -18
ファイルの表示
@@ -15,23 +15,22 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
PostExternalTag
.select('external_tag_id, MAX(created_at) AS max_created_at')
.group('external_tag_id')
.to_sql
q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q =
ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id')
q = q.where('external_tags.name LIKE ?', "%#{ name }%") if name
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
@@ -72,8 +71,7 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i
tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
tag = ExternalTag.find(id)
linked_tag_names = params[:tags].to_s.split
linked_tags = nil
@@ -81,9 +79,6 @@ class NicoTagsController < ApplicationController
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
+11 -14
ファイルの表示
@@ -27,8 +27,8 @@ class PostVersionsController < ApplicationController
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_id
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))")
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))")
end
count = q.except(:select, :order, :limit, :offset).count
@@ -113,21 +113,18 @@ class PostVersionsController < ApplicationController
end
end
def build_version_tags(cur_tags, prev_tags)
def build_version_tags cur_tags, prev_tags
(cur_tags | prev_tags).map do |name|
type =
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
{
name:,
type:
}
{ name:, type: }
end
end
end
+6 -11
ファイルの表示
@@ -671,7 +671,6 @@ class PostsController < ApplicationController
post
.post_tags
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
@@ -689,8 +688,7 @@ class PostsController < ApplicationController
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -833,15 +831,12 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
Tag.normalise_tags!(snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
-2
ファイルの表示
@@ -10,7 +10,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -33,7 +32,6 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+47 -32
ファイルの表示
@@ -100,13 +100,14 @@ class TagsController < ApplicationController
def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true)
alias_rows =
TagName
.where('name LIKE ?', "#{ q }%")
.where('name LIKE ?', prefix)
.where.not(canonical_id: nil)
.pluck(:canonical_id, :name)
@@ -118,29 +119,57 @@ class TagsController < ApplicationController
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end
base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only
canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
tags =
if canonical_ids.present?
internal_tags =
if with_nico
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
internal_rows =
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
.limit(20)
.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
render json: tags.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
return render json: internal_rows unless with_nico
external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present_only
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { |tag|
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'deprecated_at' => nil,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'post_count' => tag.post_count,
'matched_alias' => nil }
}
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name']] }
.first(20)
render json: rows
end
def show
@@ -282,11 +311,7 @@ class TagsController < ApplicationController
parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated)
if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
if category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -334,13 +359,9 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name)
if tag.nico? || (category.present? && category == 'nico')
if category.present? && category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -547,11 +568,6 @@ class TagsController < ApplicationController
end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed
@@ -661,8 +677,7 @@ class TagsController < ApplicationController
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
with_no_deerjikist: false)
old_parent_tags = tag.parents.to_a
+20
ファイルの表示
@@ -0,0 +1,20 @@
class ExternalTag < ApplicationRecord
enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
has_many :post_external_tags, dependent: :delete_all
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: :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')
end
end
+5 -2
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord
belongs_to :tag
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true
end
+24 -13
ファイルの表示
@@ -92,6 +92,9 @@ class Post < ApplicationRecord
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post
has_many :post_external_tags, dependent: :delete_all
has_many :external_tags, through: :post_external_tags
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
@@ -150,19 +153,27 @@ class Post < ApplicationRecord
end
def snapshot_tags_json
post_tags
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tags.id')
.map do |pt|
{ 'id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name,
'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} }
end
tag_snapshots =
post_tags
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tags.id')
.map { |pt|
{ 'tag_id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name,
'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} }
}
external_tag_snapshots =
post_external_tags.order(:external_tag_id).map {
{ 'external_tag_id' => _1.external_tag_id }
}
tag_snapshots + external_tag_snapshots
end
def self.section_literal section
+4
ファイルの表示
@@ -0,0 +1,4 @@
class PostExternalTag < ApplicationRecord
belongs_to :post
belongs_to :external_tag
end
+11 -28
ファイルの表示
@@ -28,11 +28,10 @@ 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: :tag_id, dependent: :destroy
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
@@ -50,7 +49,6 @@ class Tag < ApplicationRecord
has_many :materials
has_many :tag_versions
has_many :nico_tag_versions
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
@@ -65,17 +63,15 @@ class Tag < ApplicationRecord
character: 'character',
general: 'general',
material: 'material',
nico: 'nico',
meta: 'meta'
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :nico_tag_name_must_start_with_nico
validate :tag_name_mustnt_start_with_nico
validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
def self.nico_tags = ExternalTag.where(platform: :nico)
CATEGORY_PREFIXES = {
'general:' => :general,
@@ -110,10 +106,9 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false,
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
@@ -273,12 +268,8 @@ class Tag < ApplicationRecord
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.destroy!
if source_tag.nico?
source_tag_name.destroy!
else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
end
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
@@ -305,11 +296,9 @@ class Tag < ApplicationRecord
private
def nico_tag_name_must_start_with_nico
n = name.to_s
if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
def tag_name_mustnt_start_with_nico
if name.to_s.downcase.start_with?('nico:')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
@@ -350,10 +339,4 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end
+9 -6
ファイルの表示
@@ -1,19 +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.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
{ name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end
def tracks_version_no_on_record? = false
end
+21 -23
ファイルの表示
@@ -20,23 +20,22 @@ class PostCreatePlan
parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids)
{
url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
{ url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
end
private
@@ -50,7 +49,6 @@ class PostCreatePlan
tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name[tag_name]
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
direct_tag_specs << {
@@ -139,14 +137,14 @@ class PostCreatePlan
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{
name: tag.name,
category: tag.category.to_sym } }
{ name: tag.name,
category: tag.category.to_sym }
}
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
def merge_tag_specs specs
specs.each_with_object({ }) do |spec, merged|
specs.each_with_object({ }) { |spec, merged|
merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]]
@@ -155,7 +153,7 @@ class PostCreatePlan
name: spec[:name],
category: spec[:category] }
end
end.values.sort_by { _1[:name] }
}.values.sort_by { _1[:name] }
end
def existing_tags_by_name
+4 -20
ファイルの表示
@@ -1,32 +1,16 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
return if tag.tag_versions.exists?
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
def self.record_tag_snapshot! tag, created_by_user:
event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:)
end
+5 -1
ファイルの表示
@@ -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 }, " +
+54
ファイルの表示
@@ -0,0 +1,54 @@
class CreateExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :external_tags do |t|
t.string :platform, limit: 16, null: false
t.string :name, limit: 255, null: false
t.integer :post_count, null: false, default: 0
t.datetime :created_at, null: false
t.index [:platform, :name], unique: true
end
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
t.id
, 'nico' AS platform
, SUBSTR(tn.name, 6) AS name
, t.post_count
, t.created_at
FROM
tags t
INNER JOIN
tag_names tn
ON
tn.id = t.tag_name_id
AND t.category = 'nico'
SQL
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
ntv.tag_id
, 'nico' AS platform
, SUBSTR(ntv.name, 6) AS name
, 0 AS post_count
, ntv.created_at
FROM
nico_tag_versions ntv
LEFT JOIN
external_tags et
ON
et.id = ntv.tag_id
WHERE
ntv.version_no = 1
AND et.id IS NULL
SQL
end
def down
drop_table :external_tags
end
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
class CreatePostExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t|
t.references :post, null: false, index: false, foreign_key: true
t.references :external_tag, null: false, foreign_key: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
post_external_tags(post_id, external_tag_id, created_at)
SELECT
pt.post_id
, pt.tag_id AS external_tag_id
, pt.created_at
FROM
post_tags pt
INNER JOIN
tags t
ON
pt.tag_id = t.id
AND t.category = 'nico'
SQL
end
def down
drop_table :post_external_tags
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id
add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id
end
end
+199
ファイルの表示
@@ -0,0 +1,199 @@
class MigrateExternalTags < ActiveRecord::Migration[8.0]
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
def up
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
post_tag_sections pts
INNER JOIN
tags t
ON
t.id = pts.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "post_tag_sections に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
materials m
INNER JOIN
tags t
ON
t.id = m.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "materials に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.id = ti.tag_id
OR t.id = ti.parent_tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "tag_implications に #{ x } 件のチンカスがあります!"
end
execute <<~SQL
DELETE
ts
FROM
tag_similarities ts
INNER JOIN
tags t
ON
t.category = 'nico'
AND (t.id = ts.tag_id
OR t.id = ts.target_tag_id)
SQL
execute <<~SQL
DELETE
tset
FROM
theatre_skip_event_tags tset
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = tset.tag_id
SQL
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
say_with_time 'Migrate post_versions.tags_json' do
count = 0
MigrationPostVersion.find_each(batch_size: 500) do |version|
tags = version.tags_json.map do |tag|
if tag.fetch('category') == 'nico'
{ 'external_tag_id' => tag.fetch('id') }
else
{ 'tag_id' => tag.fetch('id'),
'version_no' => tag.fetch('version_no'),
'name' => tag.fetch('name'),
'category' => tag.fetch('category'),
'sections' => tag.fetch('sections') }
end
end
version.update_columns(tags_json: tags)
count += 1
end
count
end
add_tags_json_constraint!
tag_name_ids = connection.select_values(<<~SQL)
SELECT
tag_name_id
FROM
tags
WHERE
category = 'nico'
SQL
connection.transaction do
execute <<~SQL
DELETE
pt
FROM
post_tags pt
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = pt.tag_id
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
category = 'nico'
SQL
unless tag_name_ids.empty?
execute <<~SQL
DELETE
FROM
tag_names
WHERE
id IN (#{ tag_name_ids.join(', ') })
SQL
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def add_tags_json_constraint!
schema = { type: 'array',
items: { oneOf: [internal_tag_schema, external_tag_schema] } }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def internal_tag_schema
{ type: 'object',
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string',
enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta'] },
sections: sections_schema },
required: ['tag_id', 'version_no', 'sections'],
additionalProperties: false }
end
def external_tag_schema
{ type: 'object',
properties: { external_tag_id: { type: 'integer', minimum: 1 } },
required: ['external_tag_id'],
additionalProperties: false }
end
def sections_schema
{ type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer', minimum: 0 },
end_ms: { type: ['integer', 'null'], minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } }
end
end
生成ファイル
+20 -3
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -48,6 +48,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "name", null: false
t.integer "post_count", default: 0, null: false
t.datetime "created_at", null: false
t.index ["platform", "name"], name: "index_external_tags_on_platform_and_name", unique: true
end
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false
t.integer "input_tokens", default: 0, null: false
@@ -281,6 +289,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_external_tags", primary_key: ["post_id", "external_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "external_tag_id", null: false
t.datetime "created_at", null: false
t.index ["external_tag_id"], name: "index_post_external_tags_on_external_tag_id"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
@@ -352,7 +367,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"tag_id\",\"version_no\",\"sections\"],\"additionalProperties\":false},{\"type\":\"object\",\"properties\":{\"external_tag_id\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"external_tag_id\"],\"additionalProperties\":false}]}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -700,9 +715,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "external_tags", column: "nico_tag_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_external_tags", "external_tags"
add_foreign_key "post_external_tags", "posts"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
+56 -32
ファイルの表示
@@ -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.not_nico.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|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw)
event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
unless tag.nico_tag_versions.exists?
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
+115
ファイルの表示
@@ -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('<html></html>'))
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