コミットを比較

...
13 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 9c4d3285f3 #422 2026-10-04 18:11:34 +09:00
みてるぞ 6cd1fd43ee #422 2026-10-04 17:57:58 +09:00
みてるぞ d5b5fc1646 #422 2026-10-04 17:57:46 +09:00
みてるぞ 6f2f56b7b4 #422 2026-10-04 17:23:44 +09:00
みてるぞ 593e867fdd q 2026-10-04 16:18:33 +09:00
みてるぞ 93e2f48355 #422 2026-10-04 15:24:27 +09:00
みてるぞ bf0a2977fc #422 Spec 整備 2026-10-04 14:53:40 +09:00
みてるぞ 11eedc242d #422 2026-10-04 13:45:23 +09:00
みてるぞ e6ab941774 多言語化の基盤作り (#55) (#421)
Reviewed-on: #421
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-10-03 02:00:09 +09:00
みてるぞ f336b2f13b 外部タグを分離 (#419) (#420)
**本番 DB のバックアップをかならずすること**

Reviewed-on: #420
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-25 01:15:06 +09:00
みてるぞ 785b5ef8bc tags および tag_names にて論理削除を物理削除に変更 (#416) (#418)
Reviewed-on: #418
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-21 07:39:58 +09:00
みてるぞ 7f0efc3a46 post_tags の論理削除と履歴管理を廃止 (#411) (#417)
Reviewed-on: #417
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-09-21 06:02:41 +09:00
みてるぞ a947032247 ニジラー情報で他ニジラー・タグの持つ情報を奪へてしまふ問題 (#406) (#407)
画面確認がまだ.

Reviewed-on: #407
2026-09-20 03:23:36 +09:00
151個のファイルの変更、8120行の追加、2064行の削除
+2
ファイルの表示
@@ -92,4 +92,6 @@ class ApplicationController < ActionController::API
value
end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end
+6 -3
ファイルの表示
@@ -1,5 +1,7 @@
class DeerjikistsController < ApplicationController
def show
locale = resolve_locale!
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank?
@@ -7,10 +9,10 @@ class DeerjikistsController < ApplicationController
deerjikist = Deerjikist
.joins(:tag)
.includes(tag: :tag_name)
.includes(tag: :tag_names)
.find_by(platform:, code:)
if deerjikist
render json: DeerjikistRepr.base(deerjikist)
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code)
else
head :not_found
end
@@ -20,6 +22,7 @@ class DeerjikistsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
platform = params[:platform].to_s.strip
code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i
@@ -32,7 +35,7 @@ class DeerjikistsController < ApplicationController
d.save!
end
render json: DeerjikistRepr.base(deerjikist)
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code)
end
def destroy
+2 -2
ファイルの表示
@@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController
def index
posts =
Post
.preload(:post_similarities, tags: :tag_name)
.preload(:post_similarities, tags: :tag_names)
.with_attached_thumbnail
.order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
@@ -56,7 +56,7 @@ class GekanatorPostsController < ApplicationController
def tag_json tag
{
id: tag.id,
name: tag.name,
name: tag.name('ja'),
category: tag.category
}
end
+2 -2
ファイルの表示
@@ -126,9 +126,9 @@ class GekanatorQuestionsController < ApplicationController
end
Tag
.joins(:tag_name)
.joins(:tag_names)
.where(category: categories.uniq)
.where(tag_names: { name: names.uniq })
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names.uniq })
.where.not(deprecated_at: nil)
.pluck('tags.category', 'tag_names.name')
.each_with_object({ }) do |(category, name), h|
+41 -20
ファイルの表示
@@ -7,6 +7,8 @@ class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index
locale = resolve_locale!
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -20,7 +22,7 @@ class MaterialsController < ApplicationController
q = Material.includes(:material_export_items,
thumbnail_attachment: :blob,
file_attachment: :blob,
tag: :tag_name)
tag: :tag_names)
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
q = apply_material_tag_filter(q, filters, tag_graph)
@@ -42,7 +44,10 @@ class MaterialsController < ApplicationController
.offset(offset)
.to_a
response = { materials: MaterialRepr.list_many(materials, host: request.base_url),
response = { materials: MaterialRepr.list_many(
materials,
host: request.base_url,
language_code: locale.language_code),
count: }
if filters[:tag_id]
response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
@@ -55,6 +60,8 @@ class MaterialsController < ApplicationController
end
def show
locale = resolve_locale!
material =
Material
.includes(:tag, :material_export_items)
@@ -63,15 +70,17 @@ class MaterialsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless material
wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
render json: MaterialRepr.base(material, host: request.base_url,
language_code: locale.language_code).merge(wiki_page_body:)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file)
@@ -89,13 +98,14 @@ class MaterialsController < ApplicationController
begin
Material.transaction do
tag = resolve_material_tag!(tag_name_raw)
tag = resolve_material_tag!(locale, tag_name_raw)
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user,
language_code: locale.language_code)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user)
@@ -107,7 +117,8 @@ class MaterialsController < ApplicationController
if material
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
render json: MaterialRepr.base(material, host: request.base_url), status: :created
render json: MaterialRepr.base(material, host: request.base_url,
language_code: locale.language_code), status: :created
else
render_validation_error material
end
@@ -132,12 +143,14 @@ class MaterialsController < ApplicationController
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block
locale = resolve_locale!
uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
begin
Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag = resolve_material_tag!(tag_name_raw)
tag = resolve_material_tag!(locale, tag_name_raw)
material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob
material.file.attach(uploaded_blob)
@@ -145,7 +158,8 @@ class MaterialsController < ApplicationController
material.file.detach
end
material.save!
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user,
language_code: locale.language_code)
upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user)
@@ -157,7 +171,8 @@ class MaterialsController < ApplicationController
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material))
render json: MaterialRepr.base(material, host: request.base_url)
render json: MaterialRepr.base(material, host: request.base_url,
language_code: locale.language_code)
end
def destroy
@@ -236,10 +251,8 @@ class MaterialsController < ApplicationController
nil
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
def resolve_material_tag! locale, tag_name_raw
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
end
def material_index_needs_tag_name? filters
@@ -253,7 +266,12 @@ class MaterialsController < ApplicationController
end
def material_index_join_tag_name q
q.left_joins(tag: :tag_name)
q.left_joins(:tag).joins(<<~SQL.squish)
LEFT JOIN tag_names
ON tag_names.tag_id = tags.id
AND tag_names.language_code = 'ja'
AND tag_names.primary_flg = TRUE
SQL
end
def material_index_join_file_blob q
@@ -269,9 +287,11 @@ class MaterialsController < ApplicationController
def apply_material_query q, term
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
'material_file_blobs.filename LIKE :q',
q: like)
q.where(<<~SQL.squish, q: like)
tag_names.name LIKE :q
OR materials.url LIKE :q
OR material_file_blobs.filename LIKE :q
SQL
end
def apply_material_media_kind q, media_kind
@@ -323,8 +343,9 @@ class MaterialsController < ApplicationController
end
tags_by_id =
Tag
.joins(:tag_name)
.where(id: scope_tag_ids)
.joins(:tag_names)
.where(id: scope_tag_ids,
tag_names: { language_code: 'ja', primary_flg: true })
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), hash|
hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? }
+67 -30
ファイルの表示
@@ -1,5 +1,7 @@
class NicoTagsController < ApplicationController
def index
locale = resolve_locale!
name = params[:name].presence
linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence
@@ -15,30 +17,36 @@ 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')
.includes(linked_tags: { tag_names: :wiki_page })
if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
"%#{ name }%", "%#{ name }")
end
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
Tag
.joins(:tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true })
.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
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
@@ -46,54 +54,68 @@ class NicoTagsController < ApplicationController
sort_sql =
case order[0]
when 'name'
'tag_names.name'
'external_tags.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"tags.#{ order[0] }"
"external_tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
tags = q.reselect('external_tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge(
external_tag_json(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
linked_tags: tag.linked_tags.map { |lt|
TagRepr.base(lt, language_code: locale.language_code)
})
}, count: }
end
def show
tag = ExternalTag.find(params[:id])
render json: external_tag_json(tag)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
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
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
linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
with_tagme: false,
with_no_deerjikist: false)
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
TagVersioning.record_tag_snapshots!(
linked_tags,
created_by_user: current_user,
language_code: locale.language_code)
tag.linked_tags = linked_tags
tag.save!
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :update,
created_by_user: current_user)
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
render json: tag.linked_tags.map { |t|
TagRepr.base(t, language_code: locale.language_code)
}, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
@@ -102,6 +124,21 @@ class NicoTagsController < ApplicationController
private
def external_tag_json tag
{ id: tag.id,
name: "#{ tag.platform }:#{ tag.name }",
category: 'nico',
post_count: tag.post_count,
created_at: tag.created_at,
updated_at: tag.created_at,
deprecated_at: nil,
aliases: [],
parents: [],
has_wiki: false,
material_id: nil,
has_deerjikists: false }
end
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
+63 -55
ファイルの表示
@@ -2,6 +2,9 @@ class PostVersionsController < ApplicationController
def index
post_id = params[:post].presence
tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i
return head :bad_request if tag_id && external_tag_id
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -26,9 +29,14 @@ class PostVersionsController < ApplicationController
'prev.original_created_from AS prev_original_created_from',
'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 }))")
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
'OR JSON_CONTAINS(prev.tags_json,' +
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))")
elsif 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
@@ -43,50 +51,19 @@ class PostVersionsController < ApplicationController
private
def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
snapshots = rows.flat_map { |row|
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row|
cur_tags =
normalise_json(row.tags_json)
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
prev_tags =
(normalise_json(row.attributes['prev_tags_json']) || [])
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
{ post_id: row.post_id,
version_no: row.version_no,
@@ -113,21 +90,52 @@ class PostVersionsController < ApplicationController
end
end
def build_version_tags(cur_tags, prev_tags)
def external_tag_names_for snapshots
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"]
}
missing_ids = ids - names.keys
NicoTagVersion
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end
def snapshot_tag_literals snapshots, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
[tag_category_order(snapshot['category']),
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
def tag_category_order category
['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6
end
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
+103 -115
ファイルの表示
@@ -7,6 +7,8 @@ class PostsController < ApplicationController
end
def index
locale = resolve_locale!
url = params[:url].presence
title = params[:title].presence
original_created_from = params[:original_created_from].presence
@@ -36,8 +38,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
pt_max_sql =
PostTag
.select('post_id, MAX(updated_at) AS max_updated_at')
PostVersion
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
@@ -46,13 +48,12 @@ class PostsController < ApplicationController
'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q =
filtered_posts
filtered_posts(locale)
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.preload(:external_tags, :uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -91,7 +92,8 @@ class PostsController < ApplicationController
q = q.except(:select, :order)
render json: { posts: posts.map { |post|
PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
PostRepr.base(post, language_code: locale.language_code)
.merge(updated_at: post.updated_at_all).tap do |json|
json['thumbnail'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -103,16 +105,19 @@ class PostsController < ApplicationController
end
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
locale = resolve_locale!
post =
filtered_posts(locale)
.preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
return head :not_found unless post
render json: PostRepr.base(post, current_user)
render json: PostRepr.base(post, current_user, language_code: locale.language_code)
end
def metadata
@@ -187,12 +192,13 @@ class PostsController < ApplicationController
end
def show
locale = resolve_locale!
post =
Post
.includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_names: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
@@ -206,18 +212,21 @@ class PostsController < ApplicationController
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post))
related:,
language_code: locale.language_code)
.merge(tags: build_tag_tree_for(post, locale.language_code))
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
host: request.base_url).run
host: request.base_url).run(locale)
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url])
@@ -243,10 +252,10 @@ class PostsController < ApplicationController
:post_tag_specs,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!
thumbnail: params[:thumbnail])).create!(locale)
post.reload
render json: PostRepr.base(post), status: :created
render json: PostRepr.base(post, language_code: locale.language_code), status: :created
rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError
@@ -272,13 +281,18 @@ class PostsController < ApplicationController
return head :forbidden unless current_user.gte_member?
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
locale = resolve_locale!
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
host: request.base_url).run(locale)
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
@@ -311,6 +325,7 @@ class PostsController < ApplicationController
base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no)
locale = Locale.find_by(code: params[:locale].presence) || Locale.nipponese
title = params[:title].presence
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
@@ -330,9 +345,10 @@ class PostsController < ApplicationController
base_version = post.post_versions.find_by!(version_no: base_version_no)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
current_snapshot = post_snapshot_from_record(post, locale)
end
incoming_snapshot = post_incoming_snapshot(title:,
incoming_snapshot = post_incoming_snapshot(locale:,
title:,
original_created_from:,
original_created_before:,
tag_names:,
@@ -361,14 +377,14 @@ class PostsController < ApplicationController
end
end
apply_post_snapshot!(post, snapshot_to_apply)
apply_post_snapshot!(locale, post, snapshot_to_apply)
end
return render json: conflict_json, status: :conflict if conflict_json
post.reload
json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post)
json = PostRepr.base(post, current_user, language_code: locale.language_code)
json['tags'] = build_tag_tree_for(post, locale.language_code)
render json:, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
@@ -384,65 +400,21 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def changes
id = params[:id].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
tag = TagRepr.base(pt.tag)
post = pt.post
events << Event.new(
post:,
tag:,
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
change_type: 'add',
timestamp: pt.created_at)
if pt.discarded_at
events << Event.new(
post:,
tag:,
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
change_type: 'remove',
timestamp: pt.discarded_at)
end
end
events.sort_by!(&:timestamp)
events.reverse!
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
end
private
def filtered_posts
def filtered_posts locale
tag_names = params[:tags].to_s.split
match_type = params[:match]
if tag_names.present?
filter_posts_by_tags(tag_names, match_type)
filter_posts_by_tags(locale, tag_names, match_type)
else
Post.all
end
end
def filter_posts_by_tags tag_names, match_type
def filter_posts_by_tags locale, tag_names, match_type
literals = tag_names.map do |raw_name|
{ name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first,
negative: raw_name.downcase.start_with?('not:') }
end
@@ -450,11 +422,11 @@ class PostsController < ApplicationController
if match_type == 'any'
literals.reduce(Post.none) do |posts, literal|
posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative]))
end
else
literals.reduce(Post.all) do |posts, literal|
ids = tagged_post_ids_for(literal[:name])
ids = tagged_post_ids_for(locale, literal[:name])
if literal[:negative]
posts.where.not(id: ids)
else
@@ -464,8 +436,8 @@ class PostsController < ApplicationController
end
end
def tag_literal_relation name, negative:
ids = tagged_post_ids_for(name)
def tag_literal_relation locale, name, negative:
ids = tagged_post_ids_for(locale, name)
if negative
Post.where.not(id: ids)
else
@@ -473,8 +445,20 @@ class PostsController < ApplicationController
end
end
def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def tagged_post_ids_for locale, name
posts_by_internal_tags =
Post
.joins(tags: :tag_names)
.where(tag_names: { language_code: locale.language_code, name: })
.select(:id)
posts_by_external_tags =
Post
.joins(:external_tags)
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
end
def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t|
@@ -502,13 +486,13 @@ class PostsController < ApplicationController
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end
end
def build_tag_tree_for post
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
def build_tag_tree_for post, language_code = 'ja'
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
@@ -537,7 +521,7 @@ class PostsController < ApplicationController
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [], sections:)
return TagRepr.inline(tag, language_code:).merge(children: [], sections:)
end
if memo.key?(tag_id)
@@ -549,10 +533,13 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
memo[tag_id] = TagRepr.inline(tag, language_code:).merge(children:, sections:)
end
root_ids.filter_map { |id| build_node.call(id, []) }
internal_tags = root_ids.filter_map { |id| build_node.call(id, []) }
external_tags =
post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) }
internal_tags + external_tags
end
def sibling_posts_by_parent parent_post_ids
@@ -700,45 +687,48 @@ class PostsController < ApplicationController
def editable_tag_names_from_version version
version.tags_json
.reject { _1.fetch('category') == 'nico' }
.select { _1.key?('tag_id') }
.map { Post.tag_snapshot_literal(_1) }
.compact
.sort
end
def post_snapshot_from_record post
def post_snapshot_from_record post, locale
{ title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post),
tag_names: editable_tag_names_from_post(post, locale.language_code),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end
def editable_tag_names_from_post post
def editable_tag_names_from_post post, language_code
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.joins(tag: :tag_names)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.where(tag_names: { language_code:, primary_flg: true })
.includes(:sections, tag: :tag_names)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
name = post_tag.tag.name(language_code)
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end.compact.sort
end
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:,
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: }
Tag.normalise_tags!(locale, tag_names,
with_tagme: false,
with_no_deerjikist: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -749,7 +739,7 @@ class PostsController < ApplicationController
original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before),
tag_names: tags.uniq(&:id).map { |tag|
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
"#{ tag.name(locale.language_code) }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort,
parent_post_ids: parent_post_ids.sort }
end
@@ -873,7 +863,7 @@ class PostsController < ApplicationController
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end
def apply_post_snapshot! post, snapshot
def apply_post_snapshot! locale, post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title],
@@ -881,15 +871,13 @@ 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!(locale, snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user,
language_code: locale.language_code)
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)
+73
ファイルの表示
@@ -0,0 +1,73 @@
class TagDescriptionsController < ApplicationController
def show
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def show_by_title
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
body = params[:body].to_s
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
if TagDescription.exists?(tag:, language_code: 'ja')
return render_unprocessable_entity('説明はすでに存在します.')
end
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: 'ja')
render json: representation(description), status: :created
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
body = params[:body].to_s
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
return head :not_found unless description
TagDescriptionOperation.update!(
tag: description.tag, body:, updated_by_user: current_user)
description.reload
render json: representation(description)
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
end
private
def representation description
description.as_json(only: [
:tag_id,
:language_code,
:body,
:version_no,
:created_by_user_id,
:updated_by_user_id,
:created_at,
:updated_at])
end
end
+20 -8
ファイルの表示
@@ -1,5 +1,6 @@
class TagVersionsController < ApplicationController
def index
locale = resolve_locale!
tag_id = params[:id].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
@@ -18,7 +19,8 @@ class TagVersionsController < ApplicationController
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -27,18 +29,20 @@ class TagVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
render json: { versions: serialise_versions(versions, locale.language_code), count: }
end
private
def serialise_versions rows
def serialise_versions rows, language_code
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row|
cur_aliases = split_values(row.aliases)
prev_aliases = split_values(row.attributes['prev_aliases'])
current_names = names_for_locale(row.names, language_code)
previous_names = names_for_locale(row.attributes['prev_names'], language_code)
cur_aliases = current_names.fetch('aliases', []).map { _1.fetch('name') }
prev_aliases = previous_names.fetch('aliases', []).map { _1.fetch('name') }
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids)
prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
@@ -47,21 +51,22 @@ class TagVersionsController < ApplicationController
tags_by_id =
Tag
.includes(:tag_name, :materials, { tag_name: :wiki_page })
.includes(:tag_names, :materials, tag_names: :wiki_page)
.where(id: all_parent_tag_ids)
.index_by(&:id)
parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]]),
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:),
type: h[:type] }
end
{ tag_id: row.tag_id,
version_no: row.version_no,
event_type: row.event_type,
name: { current: row.name, prev: row.attributes['prev_name'] },
names: row.names,
name: { current: current_names['name'], prev: previous_names['name'] },
category: { current: row.category, prev: row.attributes['prev_category'] },
deprecated_at: { current: row.deprecated_at&.iso8601,
prev: row.attributes['prev_deprecated_at']&.iso8601 },
@@ -91,5 +96,12 @@ class TagVersionsController < ApplicationController
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?)
def names_for_locale names, language_code
values = names.is_a?(String) ? JSON.parse(names) : names
values&.fetch(language_code, {}) || {}
rescue JSON::ParserError
{}
end
def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end
+509 -180
ファイルの表示
@@ -5,6 +5,8 @@ require 'set'
class TagsController < ApplicationController
def index
locale = resolve_locale!
post_id = params[:post]
name = params[:name].presence
@@ -34,49 +36,161 @@ class TagsController < ApplicationController
offset = (page - 1) * limit
q =
tags =
if post_id.present?
Tag.joins(:posts, :tag_name)
Tag
.joins(:posts, :tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
posts: { id: post_id })
else
Tag.joins(:tag_name)
Tag
.joins(:tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
end
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present?
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category:) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
if deprecated_given
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
external_tags =
if post_id.present?
ExternalTag.joins(:posts).where(posts: { id: post_id })
else
ExternalTag.all
end
if name
tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
external_tags =
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
"%#{ name }%")
end
if category == 'nico'
tags = tags.none
elsif category
tags = tags.where(category:)
external_tags = external_tags.none
end
if post_count_between[0]
tags = tags.where('tags.post_count >= ?', post_count_between[0])
external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0])
end
if post_count_between[1]
tags = tags.where('tags.post_count <= ?', post_count_between[1])
external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1])
end
if created_between[0]
tags = tags.where('tags.created_at >= ?', created_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0])
end
if created_between[1]
tags = tags.where('tags.created_at <= ?', created_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1])
end
if updated_between[0]
tags = tags.where('tags.updated_at >= ?', updated_between[0])
external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0])
end
if updated_between[1]
tags = tags.where('tags.updated_at <= ?', updated_between[1])
external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1])
end
if deprecated_given
if deprecated
tags = tags.where.not(deprecated_at: nil)
external_tags = external_tags.none
else
tags = tags.where(deprecated_at: nil)
end
end
tag_sql = tags.select("'tag' AS source",
'tags.id',
'tag_names.name',
'tags.category',
'tags.post_count',
'tags.created_at',
'tags.updated_at',
'tags.deprecated_at').to_sql
external_tag_sql = external_tags.select(
"'external_tag' AS source",
'external_tags.id',
"CONCAT(external_tags.platform, ':', external_tags.name) AS name",
"'nico' AS category",
'external_tags.post_count',
'external_tags.created_at',
'external_tags.created_at AS updated_at',
'NULL AS deprecated_at').to_sql
union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }"
sort_sql =
case order[0]
when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
if order[0] == 'category'
'CASE category ' +
"WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 END"
"WHEN 'nico' THEN 6 " +
'END'
else
"tags.#{ order[0] }"
order[0]
end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
render json: { tags: TagRepr.many(tags), count: q.size }
connection = ApplicationRecord.connection
count = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
(#{ union_sql }) legacy_tags
SQL
rows = connection.select_all(<<~SQL).to_a
SELECT
source
, id
FROM
(#{ union_sql }) legacy_tags
ORDER BY
#{ sort_sql } #{ order[1] }
, id #{ order[1] }
, source #{ order[1] }
LIMIT
#{ limit }
OFFSET
#{ offset }
SQL
tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] }
external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] }
tags_by_id =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
id: tag_ids)
.index_by(&:id)
external_tags_by_id =
ExternalTag
.where(id: external_tag_ids)
.index_by(&:id)
render json: { tags: rows.map { |row|
if row['source'] == 'tag'
TagRepr.base(tags_by_id.fetch(row['id']), language_code: locale.language_code)
else
external_tag_json(external_tags_by_id.fetch(row['id']))
end
}, count: }
end
def with_depth
@@ -84,7 +198,8 @@ class TagsController < ApplicationController
parent_tag_id = nil if parent_tag_id <= 0
material_filter = material_filter_param(default: 'any')
graph = build_with_depth_graph(material_filter)
locale = resolve_locale!
graph = build_with_depth_graph(material_filter, locale.language_code)
tag_ids =
if parent_tag_id
@@ -99,137 +214,223 @@ class TagsController < ApplicationController
end
def autocomplete
locale = resolve_locale!
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.not(canonical_id: nil)
.pluck(:canonical_id, :name)
.where(language_code: locale.language_code, primary_flg: false)
.where('name LIKE ?', prefix)
.pluck(:tag_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
matched_alias_by_tag_id = { }
tag_ids = []
alias_rows.each do |tag_id, alias_name|
tag_ids << tag_id
matched_alias_by_tag_id[tag_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_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
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?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq))
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, language_code: locale.language_code)
.merge(matched_alias: matched_alias_by_tag_id[tag.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 { external_tag_json(_1) }
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name'] || row[:name]] }
.first(20)
render json: rows
end
def show
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(id: params[:id])
if tag
render json: TagRepr.base(tag)
else
head :not_found
end
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
external_tag = ExternalTag.find_by(id: params[:id])
return render json: ExternalTagRepr.base(external_tag) if external_tag
head :not_found
end
def show_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: TagRepr.base(tag)
else
head :not_found
end
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
platform, external_name = name.split(':', 2)
return head :not_found unless external_name
external_tag = ExternalTag.find_by(platform:, name: external_name)
return head :not_found unless external_tag
render json: ExternalTagRepr.base(external_tag)
end
def deerjikists
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
locale = resolve_locale!
tag =
Tag.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
render json: { tag: TagRepr.base(tag, language_code: locale.language_code),
deerjikists: DeerjikistRepr.many(
tag.deerjikists,
language_code: locale.language_code) }
end
def deerjikists_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many(tag.deerjikists) }
render json: { tag: TagRepr.base(tag, language_code: locale.language_code),
deerjikists: DeerjikistRepr.many(
tag.deerjikists,
language_code: locale.language_code) }
end
def update_deerjikists
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
locale = resolve_locale!
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, tag_names: :wiki_page)
.find_by(id: params[:id],
tag_names: { language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
row_indexes_by_key = rows_by_key(rows)
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
current_deerjikists = locked_deerjikists.filter { |deerjikist|
deerjikist.tag_id == tag.id
}
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
}
render_deerjikist_conflicts(locale, requested_deerjikists, row_indexes_by_key, tag)
raise ActiveRecord::Rollback if performed?
requested_keys_set = requested_keys.to_set
current_deerjikists.each do |deerjikist|
key = [deerjikist.platform, deerjikist.code]
deerjikist.destroy! unless requested_keys_set.include?(key)
end
existing_keys = requested_deerjikists.to_h { |deerjikist|
[[deerjikist.platform, deerjikist.code], true]
}
requested_keys.each do |platform, code|
next if existing_keys[[platform, code]]
deerjikist = Deerjikist.new(platform:, code:, tag:)
row_index = row_indexes_by_key[[platform, code]].first
begin
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
rescue ActiveRecord::RecordNotUnique
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
render_deerjikist_conflicts(locale, conflicts, row_indexes_by_key, tag)
end
raise ActiveRecord::Rollback if performed?
end
end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists)
render json: DeerjikistRepr.many(tag.reload.deerjikists,
language_code: locale.language_code)
end
def materials_by_name
name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank?
locale = resolve_locale!
material_filter = material_filter_param(default: 'any')
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
tag =
Tag
.joins(:tag_names)
.includes(:tag_names, :materials, tag_names: :wiki_page)
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
return head :not_found unless tag
graph = build_with_depth_graph(material_filter)
graph = build_with_depth_graph(material_filter, locale.language_code)
render json: build_tag_children(tag, graph:)
render json: build_tag_children(tag, graph:, language_code: locale.language_code)
end
def update_all
@@ -245,39 +446,38 @@ class TagsController < ApplicationController
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
return unless validate_tag_rename(tag, name)
locale = resolve_locale!
return unless validate_tag_rename(locale, tag, name)
alias_names = params[:aliases].to_s.split.uniq
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
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user,
language_code: locale.language_code)
old_name = tag.name
old_name = tag.name(locale.language_code)
name_changed = name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
if tag.deprecated? == deprecated
tag.update!(category:)
else
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
end
rename_tag_name!(tag, name) if name_changed
rename_tag_name!(locale, tag, name) if name_changed
alias_names << old_name if name_changed
alias_names << old_name if name_changed && old_name.present?
alias_names.delete(name)
update_aliases!(tag, alias_names)
update_parent_tags!(tag, parent_names)
update_aliases!(locale, tag, alias_names)
update_parent_tags!(locale, tag, parent_names)
tag.reload
@@ -285,17 +485,20 @@ class TagsController < ApplicationController
tag,
event_type: :update,
created_by_user: current_user,
language_code: locale.language_code,
name_changed:,
wiki_page:)
end
render json: TagRepr.base(tag.reload)
render json: TagRepr.base(tag.reload, language_code: locale.language_code)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
name = params[:name].presence
category = params[:category].presence
deprecated_given = params.key?(:deprecated)
@@ -303,24 +506,21 @@ 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(locale, tag, name)
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
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user,
language_code: locale.language_code)
old_name = tag.name
old_name = tag.name(locale.language_code)
name_changed = name.present? && name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
rename_tag_name!(tag, name) if name_changed
rename_tag_name!(locale, tag, name) if name_changed
tag.update!(category:) if category.present?
if deprecated_given && tag.deprecated? != deprecated
tag.update!(deprecated_at: deprecated ? Time.current : nil)
@@ -332,11 +532,12 @@ class TagsController < ApplicationController
tag,
event_type: :update,
created_by_user: current_user,
language_code: locale.language_code,
name_changed:,
wiki_page:)
end
render json: TagRepr.base(tag.reload)
render json: TagRepr.base(tag.reload, language_code: locale.language_code)
end
private
@@ -348,7 +549,7 @@ class TagsController < ApplicationController
value
end
def build_with_depth_graph material_filter
def build_with_depth_graph material_filter, language_code
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
@@ -363,10 +564,13 @@ class TagsController < ApplicationController
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
tags_by_id = Tag.joins(:tag_name)
.where(id: tag_ids)
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
tags_by_id =
Tag
.joins(:tag_names)
.where(id: tag_ids,
tag_names: { language_code:, primary_flg: true })
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), h|
h[id] = { name:, category:, deprecated: deprecated_at.present?,
has_material: material_tag_ids.include?(id) }
end
@@ -498,34 +702,33 @@ class TagsController < ApplicationController
children: [] }
end
def build_tag_children tag, graph: nil
def build_tag_children tag, graph: nil, language_code: 'ja'
material = tag.materials.first
tag_graph = graph && graph[:tags_by_id][tag.id]
material =
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
children = tag.children.sort_by(&:name)
children = tag.children.sort_by { _1.name(language_code) }
if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end
TagRepr.base(tag).merge(
children: children.map { build_tag_children(_1, graph:) },
TagRepr.base(tag, language_code:).merge(
children: children.map { build_tag_children(_1, graph:, language_code:) },
has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base(material, host: request.base_url))
material: material && MaterialRepr.base(
material,
host: request.base_url,
language_code:))
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:)
def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja',
name_changed: false, wiki_page: nil
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
return unless name_changed
wiki_page ||= tag.tag_name.wiki_page
wiki_page ||= tag.tag_name('ja')&.wiki_page
return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!(
@@ -534,109 +737,140 @@ class TagsController < ApplicationController
created_by_user:)
end
def validate_tag_rename tag, name
return true if name.blank? || name == tag.name
def validate_tag_rename locale, tag, name
return true if name.blank? || name == tag.name(locale.language_code)
if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
system_tag_names = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ']
if TagName.exists?(tag_id: tag.id,
language_code: 'ja',
primary_flg: true,
name: system_tag_names)
render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
return false
end
target_tag_name = TagName.with_discarded.find_by(name:)
return true if target_tag_name.nil?
return true if target_tag_name.canonical_id?
target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
return true unless target_tag_name&.primary_flg
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
false
end
def rename_tag_name! tag, name
return if name == tag.name
def rename_tag_name! locale, tag, name
return if name == tag.name(locale.language_code)
current_tag_name = tag.tag_name
target_tag_name = TagName.with_discarded.find_by(name:)
current_tag_name = tag.tag_name(locale.language_code)
target_tag_name = TagName.find_by(language_code: locale.language_code, name:)
if target_tag_name.nil?
current_tag_name.update!(name:)
unless target_tag_name
if current_tag_name
current_tag_name.update!(name:, auto_generated: false)
else
current_tag_name = TagName.create!(
tag:,
language_code: locale.language_code,
script_code: locale.script_code,
name:,
primary_flg: true)
tag.association(:tag_names).reset
end
return
end
promote_tag_alias!(
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
old_owner_tag = promoted_tag_name.canonical&.tag
old_owner_tag = promoted_tag_name.tag
if old_owner_tag && old_owner_tag != tag
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil)
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
TagName.with_discarded
.where(canonical_id: current_tag_name.id)
current_tag_name.update!(canonical_id: promoted_tag_name.id,
primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
TagName.where(tag:,
language_code: current_tag_name.language_code,
primary_flg: false)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name)
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
end
current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name)
tag.update!(tag_name: promoted_tag_name)
tag.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical: promoted_tag_name)
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
return unless old_owner_tag && old_owner_tag != tag
record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user)
record_tag_version!(old_owner_tag.reload, event_type: :update,
created_by_user: current_user,
language_code: current_tag_name.language_code)
end
def update_aliases! tag, alias_names
def update_aliases! locale, tag, alias_names
alias_names = alias_names.uniq
affected_tags = [tag]
current_aliases = tag.tag_name.aliases.to_a
primary_tag_name = TagName.find_by!(tag:,
language_code: locale.language_code,
primary_flg: true)
current_aliases = tag.tag_names.where(language_code: locale.language_code,
primary_flg: false).to_a
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.canonical&.tag
affected_tags << alias_tag_name.tag
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
raise ActiveRecord::RecordInvalid, alias_tag_name
end
affected_tags << alias_tag_name&.tag
end
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user)
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
language_code: locale.language_code)
end
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
alias_tag_name.update!(canonical: nil)
alias_tag_name.update!(canonical_id: nil, tag_id: nil)
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name)
alias_tag_name = TagName.find_or_initialize_by(
language_code: locale.language_code,
name: alias_name)
alias_tag_name.script_code = locale.script_code
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
canonical_id: primary_tag_name.id)
end
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
record_tag_version!(affected_tag, event_type: :update,
created_by_user: current_user,
language_code: locale.language_code)
end
end
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false,
deny_nico: true)
def update_parent_tags! locale, tag, parent_names
parent_tags = Tag.normalise_tags!(locale, parent_names,
with_tagme: false,
with_no_deerjikist: false)
old_parent_tags = tag.parents.to_a
@@ -653,6 +887,7 @@ class TagsController < ApplicationController
end
def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
@@ -669,6 +904,84 @@ class TagsController < ApplicationController
nil
end
def normalise_deerjikist_rows tag
rows = []
params[:_json].each.with_index do |item, index|
platform = item[:platform]
unless Deerjikist.platforms.key?(platform)
render_deerjikist_platform_invalid(index)
return rows
end
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.new(platform:, code:, tag:)
unless deerjikist.valid?
render_deerjikist_form_record_invalid(deerjikist, index)
return rows
end
rows << { index:, platform:, code: }
end
rows
end
def lock_deerjikists_for_tag_update tag_id, keys
clauses = ['tag_id = ?']
values = [tag_id]
keys.each do |platform, code|
clauses << '(platform = ? AND code = ?)'
values << platform << code
end
Deerjikist
.where(clauses.join(' OR '), *values)
.order(:platform, :code)
.lock
.to_a
end
def render_deerjikist_conflicts locale, deerjikists, row_indexes_by_key, tag
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
return if conflicts.empty?
tag_names_by_id =
Tag
.joins(:tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
id: conflicts.map(&:tag_id).uniq)
.pluck('tags.id', 'tag_names.name')
.to_h
fields = { }
conflicts.each do |deerjikist|
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
field = :"deerjikists.#{ index }.code"
fields[field] ||= []
fields[field] << message
end
end
render_validation_error fields:
end
def render_deerjikist_platform_invalid index
render_validation_error fields: {
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
}
end
def rows_by_key rows
rows.each_with_object({ }) do |row, result|
key = [row[:platform], row[:code]]
result[key] ||= []
result[key] << row[:index]
end
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
@@ -687,4 +1000,20 @@ class TagsController < ApplicationController
render_validation_error fields:
end
def external_tag_json 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,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false }
end
end
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ class TheatreSkipEventsController < ApplicationController
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.includes(:post, tags: :tag_names)
.order(created_at: :desc)
.limit(limit)
@@ -14,7 +14,7 @@ class TheatreSkipEventsController < ApplicationController
{ id: event.id,
theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name('ja') } },
programme_position: event.programme_position,
created_at: event.created_at }
}
+146 -53
ファイルの表示
@@ -3,46 +3,62 @@ class WikiPagesController < ApplicationController
def index
title = params[:title].to_s.strip
if title.blank?
return render json: WikiPageRepr.base(
WikiPage.joins(:tag_name).includes(tag_name: :tag))
descriptions = TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names)
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions.joins(tag: :tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true })
.where('tag_names.name LIKE ?', pattern).limit(20)
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
end
q = WikiPage.joins(:tag_name).includes(tag_name: :tag)
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
render json: WikiPageRepr.base(q.limit(20))
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
json.concat(WikiPageRepr.base(pages))
json = json.first(20) if title.present?
render json:
end
def show
tag_description = TagDescription.find_by(id: params[:id])
return render_wiki_page_by_tag_description tag_description if tag_description
page = WikiPage.joins(:tag_name)
.includes(tag_name: :tag)
.find_by(id: params[:id])
render_wiki_page_or_404 page
end
def show_by_title
title = params[:title].to_s.strip
tag_description = tag_description_by_title(title)
return render_wiki_page_by_tag_description tag_description if tag_description
page = WikiPage.joins(:tag_name)
.includes(tag_name: :tag)
.find_by(tag_name: { name: title })
render_wiki_page_or_404 page
end
def exists
if WikiPage.exists?(id: params[:id])
head :no_content
else
head :not_found
end
return head :no_content if TagDescription.exists?(id: params[:id])
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
end
def exists_by_title
title = params[:title].to_s.strip
if WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
head :no_content
else
head :not_found
end
return head :no_content if tag_description_by_title(title)
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
head exists ? :no_content : :not_found
end
def diff
@@ -52,12 +68,20 @@ class WikiPagesController < ApplicationController
from = params[:from].presence
to = params[:to].presence
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return render_unprocessable_entity('差分を表示できない版です.')
description = TagDescription.find_by(id:)
if description
versions = description_versions(description)
from_rev = from && versions.find(from)
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
resource = WikiPageRepr.tag_description(description)
else
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
return render_unprocessable_entity('差分を表示できない版です.')
end
resource = WikiPageRepr.base(page)
end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
@@ -75,9 +99,9 @@ class WikiPagesController < ApplicationController
end
}.flatten.compact
render json: { wiki_page_id: page.id,
title: page.title,
deprecated_at: page.deprecated_at,
render json: { wiki_page_id: resource.fetch('id'),
title: resource.fetch('title'),
deprecated_at: resource.fetch('deprecated_at'),
older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id,
diff: diff_json }
@@ -94,14 +118,16 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title)
page = ApplicationRecord.transaction do
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
name.script_code = 'Jpan'
name.primary_flg = true
name.auto_generated = false
end
page =
Wiki::Commit.create_content!(
tag_name:,
body:,
created_by_user: current_user,
message:)
Wiki::Commit.create_content!(
tag_name:, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e
@@ -128,7 +154,7 @@ class WikiPagesController < ApplicationController
old_title = page.title
tag = Tag.find_by(tag_name_id: page.tag_name_id)
tag = page.tag_name.tag
if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
@@ -152,29 +178,35 @@ class WikiPagesController < ApplicationController
head :ok
end
def search
index
end
def changes
id = params[:id].presence
q = WikiRevision.joins(wiki_page: :tag_name)
.includes(:created_user, wiki_page: { tag_name: :tag })
.order(id: :desc)
q = q.where(wiki_page_id: id) if id
description = id && TagDescription.find_by(id:)
if description
return render json: description_changes(TagDescription.where(id: description.id))
end
render json: q.limit(200).map { |rev|
{ revision_id: rev.id,
pred: rev.base_revision_id,
succ: nil,
wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title,
deprecated_at: rev.wiki_page.deprecated_at },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind,
message: rev.message,
timestamp: rev.created_at }
}.compact
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
revisions = WikiRevision.joins(wiki_page: :tag_name)
.where(wiki_page_id: pages.select(:id))
.includes(:created_user, wiki_page: { tag_name: :tag })
.order(id: :desc).limit(200)
json = revisions.map { |rev|
{ revision_id: rev.id,
pred: rev.base_revision_id,
succ: nil,
wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title,
deprecated_at: rev.wiki_page.deprecated_at },
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind,
message: rev.message,
timestamp: rev.created_at }
}
unless id
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
json.sort_by! { |row| -row[:revision_id] }
end
render json: json.first(200)
end
private
@@ -200,6 +232,67 @@ class WikiPagesController < ApplicationController
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end
def render_wiki_page_by_tag_description description
versions = description_versions(description)
historical = params[:version].present?
rev = historical ? versions.find_by(id: params[:version]) :
versions.find_by(version_no: description.version_no)
return head :not_found unless rev
pred = versions.find_by(version_no: rev.version_no - 1)&.id
succ = versions.find_by(version_no: rev.version_no + 1)&.id
body = historical ? rev.body : description.body
updated_at = historical ? rev.created_at : description.updated_at
render json: WikiPageRepr.tag_description(description).merge(
body:, revision_id: rev.id, pred:, succ:, updated_at:)
end
def description_versions description
TagDescriptionVersion.where(tag_id: description.tag_id,
language_code: description.language_code)
end
def tag_description_by_title title
TagDescription.joins(tag: :tag_names)
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end
def unmigrated_wiki_pages
descriptions = TagDescription.where(language_code: 'ja')
WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id))
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
descriptions.select(:tag_id))
end
def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = TagDescriptionVersion.where(
language_code: 'ja', tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
versions.map do |version|
description = by_tag.fetch(version.tag_id)
user = users[version.created_by_user_id]
{ revision_id: version.id,
pred: predecessors[[version.tag_id, version.version_no - 1]],
succ: nil,
wiki_page: { id: description.id, title: description.tag.name('ja'),
deprecated_at: description.tag.deprecated_at },
user: user && { id: user.id, name: user.name },
kind: 'content',
message: nil,
timestamp: version.created_at }
end
end
def find_revision page
if params[:version].present?
page.wiki_revisions.find_by(id: params[:version])
+24
ファイルの表示
@@ -0,0 +1,24 @@
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 language_code = 'ja'
linked_tags
.joins(:tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.order('tag_names.name')
.pluck('tag_names.name')
end
end
+5
ファイルの表示
@@ -0,0 +1,5 @@
class Language < ApplicationRecord
has_many :locales, foreign_key: :language_code, primary_key: :code
enum :code, ja: 'ja'
end
+27
ファイルの表示
@@ -0,0 +1,27 @@
class Locale < ApplicationRecord
after_create_commit :generate_tag_names!
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :script, foreign_key: :script_code, primary_key: :code
enum :code, ja: 'ja', en: 'en', fr: 'fr'
def self.nipponese = Locale.find('ja')
private
def generate_tag_names!
ApplicationRecord.transaction do
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
Tag.where.not(id: tag_ids).find_each do
TagName.create!(tag_id: _1.id,
language_code:,
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
+5 -19
ファイルの表示
@@ -1,24 +1,10 @@
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
validate :nico_tag_must_be_nico
validate :tag_mustnt_be_nico
private
def nico_tag_must_be_nico
if nico_tag && nico_tag.category != 'nico'
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
end
end
def tag_mustnt_be_nico
if tag && tag.category == 'nico'
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
end
end
end
+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
+37 -24
ファイルの表示
@@ -55,25 +55,27 @@ class Post < ApplicationRecord
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, 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 :tags, through: :active_post_tags
has_many :tags, through: :post_tags
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :active_post_tags, source: :tag
through: :post_tags,
source: :tag
has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all
has_many :post_versions
has_many :gekanator_guessed_games,
class_name: 'GekanatorGame',
foreign_key: :guessed_post_id,
dependent: :delete_all,
inverse_of: :guessed_post
has_many :gekanator_correct_games,
class_name: 'GekanatorGame',
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
@@ -90,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: :destroy
has_many :external_tags, through: :post_external_tags
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
@@ -121,14 +126,14 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil)
end
def snapshot_tag_names
def snapshot_tag_names language_code = 'ja'
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.joins(tag: :tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.includes(:sections, tag: :tag_names)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
name = post_tag.tag.name(language_code)
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
@@ -148,21 +153,29 @@ class Post < ApplicationRecord
"#{ tag.fetch('name') }#{ sections.join }"
end
def snapshot_tags_json
post_tags
.kept
.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
def snapshot_tags_json language_code = 'ja'
tag_snapshots =
post_tags
.joins(tag: :tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.includes(:sections, tag: :tag_names)
.order('tags.id')
.map { |pt|
{ 'tag_id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name(language_code),
'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, counter_cache: :post_count
end
+1 -21
ファイルの表示
@@ -1,14 +1,7 @@
class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post
belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
@@ -18,18 +11,5 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: {
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
validates :post_id, uniqueness: { scope: :tag_id }
end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
+3
ファイルの表示
@@ -0,0 +1,3 @@
class Region < ApplicationRecord
;
end
+5
ファイルの表示
@@ -0,0 +1,5 @@
class Script < ApplicationRecord
has_many :locales, foreign_key: :script_code, primary_key: :code
enum :code, Jpan: 'Jpan'
end
+290 -261
ファイルの表示
@@ -2,8 +2,6 @@ require 'set'
class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError
;
end
@@ -28,15 +26,12 @@ class Tag < ApplicationRecord
end
has_many :post_tags, 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 :posts, through: :active_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 :posts, through: :post_tags
has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
class_name: 'NicoTagRelation',
foreign_key: :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
@@ -54,32 +49,22 @@ 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
has_many :tag_names
attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true
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 }
validate :nico_tag_name_must_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 }
validate :tag_names_must_be_valid
CATEGORY_PREFIXES = {
'general:' => :general,
@@ -93,8 +78,20 @@ class Tag < ApplicationRecord
'mtr:' => :material,
'meta:' => :meta }.freeze
def name= val
(self.tag_name ||= build_tag_name).name = val
SYSTEM_TAG_NAMES = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ', 'YouTube'].freeze
def tag_name language_code
if tag_names.loaded?
tag_names.find { _1.language_code == language_code && _1.primary_flg }
else
tag_names.find_by(language_code:, primary_flg: true)
end
end
def name(language_code) = tag_name(language_code)&.name
def wiki_page = tag_name('ja')&.wiki_page
def system?
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
end
def deprecated? = deprecated_at?
@@ -105,221 +102,285 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
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:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise SectionLiteralParseError.new(raw_name, raw_name)
end
name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name]
end
tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
def snapshot_aliases(language_code)
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
end
def self.expand_parent_tags tags
return [] if tags.blank?
seen = Set.new
result = []
stack = tags.compact.dup
until stack.empty?
tag = stack.pop
next unless tag
tag.parents.each do |parent|
next if seen.include?(parent.id)
seen << parent.id
result << parent
stack << parent
end
end
(result + tags).uniq { |t| t.id }
end
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
if end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
end_ms = begin_ms + 1 if begin_ms == end_ms
end
[begin_ms, end_ms]
end
def self.merge_section_ranges ranges
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
merged = []
sorted_ranges.each do |begin_ms, end_ms|
if merged.empty?
merged << [begin_ms, end_ms]
next
end
last_begin_ms, last_end_ms = merged[-1]
if last_end_ms.nil? || begin_ms <= last_end_ms
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
else
merged << [begin_ms, end_ms]
end
end
merged
end
def self.merge_section_end left_end_ms, right_end_ms
return nil if left_end_ms.nil? || right_end_ms.nil?
[left_end_ms, right_end_ms].max
end
def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category
end
rescue ActiveRecord::RecordNotUnique
retry
end
def self.merge_tags! target_tag, source_tags, created_by_user: nil
target_tag => Tag
affected_post_ids = Set.new
Tag.transaction do
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag
next if source_tag == target_tag
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id
affected_post_ids << post_id
source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag)
end
end
source_tag_name = source_tag.tag_name
if source_tag_name.wiki_page.present?
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.discard!
if source_tag.nico?
source_tag_name.discard!
else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
end
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
Post.where(id: affected_post_ids.to_a).find_each do |post|
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
end
# 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
end
target_tag.reload
end
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
def snapshot_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def snapshot_linked_tag_names language_code
linked_tags
.joins(:tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.order('tag_names.name')
.pluck('tag_names.name')
end
class << self
def tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta)
def bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta)
def no_deerjikist =
find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta)
def video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta)
def niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta)
def youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta)
def normalise_tags! locale, tag_names,
with_tagme: true,
with_no_deerjikist: true,
deny_deprecated: false,
with_sections: false
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
sections = { }
tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise SectionLiteralParseError.new(raw_name, raw_name)
end
name = TagName.canonicalise(locale, name).first
find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)]
end
tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end
end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq!(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end
def expand_parent_tags tags
return [] if tags.blank?
seen = Set.new
result = []
stack = tags.compact.dup
until stack.empty?
tag = stack.pop
next unless tag
tag.parents.each do |parent|
next if seen.include?(parent.id)
seen << parent.id
result << parent
stack << parent
end
end
(result + tags).uniq { |t| t.id }
end
def normalise_section_range! begin_raw:, end_raw:, tag_name:
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
if end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
end_ms = begin_ms + 1 if begin_ms == end_ms
end
[begin_ms, end_ms]
end
def merge_section_ranges ranges
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
merged = []
sorted_ranges.each do |begin_ms, end_ms|
if merged.empty?
merged << [begin_ms, end_ms]
next
end
last_begin_ms, last_end_ms = merged[-1]
if last_end_ms.nil? || begin_ms <= last_end_ms
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
else
merged << [begin_ms, end_ms]
end
end
merged
end
def merge_section_end left_end_ms, right_end_ms
return nil if left_end_ms.nil? || right_end_ms.nil?
[left_end_ms, right_end_ms].max
end
def find_or_create_by_tag_name! locale, name, category:
language_code = locale.language_code
name = name.to_s.strip
ApplicationRecord.transaction do
tn = TagName.find_or_create_by!(language_code:, name:) do
_1.script_code = locale.script_code
_1.primary_flg = true
end
tag = tn.tag
next tag if tag
tag = Tag.create!(tag_name_id: tn.id, category:)
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
generate_tag_names!(locale, tag, tn)
tag.association(:tag_names).reset
tag
end
rescue ActiveRecord::RecordNotUnique
retry
end
def merge_tags! target_tag, source_tags, created_by_user: nil
target_tag => Tag
affected_post_ids = Set.new
Tag.transaction do
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag
next if source_tag == target_tag
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.find_each do |source_pt|
post_id = source_pt.post_id
affected_post_ids << post_id
source_pt.destroy!
unless PostTag.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag)
end
end
source_tag_name = source_tag.tag_name('ja')
if source_tag_name.wiki_page.present?
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
updated_at: Time.current)
source_tag.destroy!
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
Post.where(id: affected_post_ids.to_a).find_each do |post|
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
end
# 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count)
end
target_tag.reload
end
def time_to_ms! str, tag_name:
match =
case str
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: Regexp.last_match[:hours],
minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
end
raise SectionLiteralParseError.new(tag_name, str) unless match
total_s = match[:seconds].to_i
total_s += match[:minutes].to_i * 60 if match[:minutes]
total_s += match[:hours].to_i * 3_600 if match[:hours]
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
private
def generate_tag_names! locale, tag, base_tag_name
ApplicationRecord.transaction do
locales =
Locale
.where(deprecated_at: nil)
.where.not(language_code: locale.language_code)
.order(:language_code, :created_at, :script_code)
.uniq { _1.language_code }
locales.each do |locale|
language_code = locale.language_code
script_code = locale.script_code
TagName.create!(tag_id: tag.id,
language_code:,
name: TagName.generate_name(locale, tag, base_tag_name),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
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, 'ニコニコ・タグの命名規則に反してゐます.'
end
end
def tag_names_must_be_valid
tag_names.each do |tag_name|
next if tag_name.valid?
def tag_name_must_be_canonical
if tag_name&.canonical_id?
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
tag_name.errors[:name].each { errors.add :name, _1 }
end
end
@@ -328,36 +389,4 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end
end
def self.time_to_ms! str, tag_name:
match =
case str
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: Regexp.last_match[:hours],
minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
end
raise SectionLiteralParseError.new(tag_name, str) unless match
total_s = match[:seconds].to_i
total_s += match[:minutes].to_i * 60 if match[:minutes]
total_s += match[:hours].to_i * 3_600 if match[:hours]
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
+3
ファイルの表示
@@ -0,0 +1,3 @@
class TagDescription < ApplicationRecord
belongs_to :tag
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class TagDescriptionVersion < ApplicationRecord
;
end
+41 -19
ファイルの表示
@@ -1,45 +1,61 @@
class TagName < ApplicationRecord
include MyDiscard
has_one :tag
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :tag, optional: true
has_one :wiki_page
belongs_to :canonical, class_name: 'TagName', optional: true
has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id
validates :name, presence: true,
length: { maximum: 255 },
uniqueness: { scope: :language_code }
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
validate :alias_must_not_have_wiki_page
validate :name_must_be_sanitised
validate :name_mustnt_start_with_nico
validate :generated_name_must_be_reserved
def self.canonicalise names
def primary? = primary_flg
def self.canonicalise locale, 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)
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
names.map { |name| tns[name]&.canonical&.name || name }.uniq
names.map { |name|
tag_name = tns[name]
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
name
else
TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end
}.uniq
end
def self.generate_name locale, tag, base_tag_name
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end
private
def canonical_must_be_canonical
if canonical&.canonical_id?
errors.add :canonical, 'canonical は実体を示す必要があります.'
def generated_name_must_be_reserved
if !auto_generated && name.to_s.start_with?('Tag_#')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
def alias_name_must_not_have_prefix
if canonical_id? && name.to_s.include?(':')
if !(primary?) && 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 の参照がある名前はエーリアスになれません.'
def alias_must_not_have_wiki_page
if !(primary?) && wiki_page
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
end
end
@@ -48,4 +64,10 @@ class TagName < ApplicationRecord
errors.add :name, '名前に使用できない文字が含まれてゐます.'
end
end
def name_mustnt_start_with_nico
if name.to_s.downcase.start_with?('nico:')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
end
+30 -21
ファイルの表示
@@ -10,35 +10,44 @@ class TagNameSanitisationRule < ApplicationRecord
validate :source_pattern_must_be_regexp
class << self
def sanitise(name) =
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
def sanitise(name)
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
end
def apply!
TagName.find_each do |tn|
name = sanitise(tn.name)
next if name == tn.name
Language.find_each do |language|
TagName.where(language:).find_each do |tn|
name = sanitise(tn.name)
next if name == tn.name
TagName.transaction do
existing_tn = TagName.find_by(name:)
if existing_tn
existing_tn = existing_tn.canonical || existing_tn
next if existing_tn.id == tn.id
TagName.transaction do
existing_tn = TagName.find_by(language:, name:)
if existing_tn
unless existing_tn.primary_flg
existing_tn = TagName.find_by!(language:,
tag_id: existing_tn.tag_id,
primary_flg: true)
end
next if existing_tn.id == tn.id
existing_tag = Tag.find_by(tag_name_id: existing_tn.id)
source_tag = Tag.find_by(tag_name_id: tn.id)
existing_tag = existing_tn.tag
source_tag = tn.tag
if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
tn.update_columns(tag_id: nil, updated_at: Time.current)
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
end
tn.destroy!
next
end
tn.discard!
next
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end
end
end
+1
ファイルの表示
@@ -11,5 +11,6 @@ class TagVersion < ApplicationRecord
meta: 'meta' }, validate: true
validates :name, presence: true
validates :names, presence: true
validates :category, presence: true
end
+7 -4
ファイルの表示
@@ -48,8 +48,11 @@ class WikiPage < ApplicationRecord
page
end
def pred_revision_id(revision_id) =
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
def succ_revision_id(revision_id) =
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end
+5 -4
ファイルの表示
@@ -6,11 +6,12 @@ module DeerjikistRepr
module_function
def base deerjikist
deerjikist.as_json(BASE)
def base deerjikist, language_code: 'ja'
deerjikist.as_json(BASE).merge(
tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code:))
end
def many deerjikists
deerjikists.map { |d| base(d) }
def many deerjikists, language_code: 'ja'
deerjikists.map { |d| base(d, language_code:) }
end
end
+20
ファイルの表示
@@ -0,0 +1,20 @@
module ExternalTagRepr
module_function
def base tag
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'post_count' => tag.post_count,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false }
end
def inline(tag) = base(tag)
end
+14 -13
ファイルの表示
@@ -12,14 +12,15 @@ module MaterialRepr
module_function
def base material, host:
def base material, host:, language_code: 'ja'
material.as_json(BASE).merge(
tag: material.tag && TagRepr.base(material.tag, language_code:),
file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:)
end,
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
file_byte_size: material.file_byte_size,
@@ -27,11 +28,11 @@ module MaterialRepr
export_items: export_items(material))
end
def many materials, host:
materials.map { |m| base(m, host:) }
def many materials, host:, language_code: 'ja'
materials.map { |m| base(m, host:, language_code:) }
end
def list material, host:
def list material, host:, language_code: 'ja'
{ id: material.id,
version_no: material.version_no,
url: material.url,
@@ -40,9 +41,9 @@ module MaterialRepr
source_path: material.source_path,
source_file_id: material.source_file_id,
normalized_source_key: material.normalized_source_key,
tag: compact_tag(material.tag),
tag: compact_tag(material.tag, language_code:),
thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:),
thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material),
content_type: material.content_type,
@@ -53,8 +54,8 @@ module MaterialRepr
export_items: export_items(material) }
end
def list_many materials, host:
materials.map { |m| list(m, host:) }
def list_many materials, host:, language_code: 'ja'
materials.map { |m| list(m, host:, language_code:) }
end
def export_paths material
@@ -79,8 +80,8 @@ module MaterialRepr
material.thumbnail, host:)
end
def thumbnail_fallback_text material
material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
def thumbnail_fallback_text material, language_code:
material.tag&.name(language_code) || material.created_at&.strftime('%Y-%m-%d')
end
def thumbnail_fallback_kind material
@@ -98,11 +99,11 @@ module MaterialRepr
'file_other'
end
def compact_tag tag
def compact_tag tag, language_code:
return nil unless tag
{ id: tag.id,
name: tag.name,
name: tag.name(language_code),
category: tag.category,
deprecated_at: tag.deprecated_at }
end
+22 -13
ファイルの表示
@@ -17,23 +17,23 @@ module PostRepr
module_function
def base post, current_user = nil, host: nil
def base post, current_user = nil, host: nil, language_code: 'ja'
json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post)
json['tags'] = tag_json(post, language_code:)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: [], host: nil
sibling_posts: { }, related: [], host: nil, language_code: 'ja'
if host.present?
base(post, current_user, host:).merge(
base(post, current_user, host:, language_code:).merge(
'parent_posts' => cards(parent_posts, host:),
'child_posts' => cards(child_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
@@ -41,7 +41,7 @@ module PostRepr
},
'related' => cards(related, host:))
else
base(post, current_user).merge(
base(post, current_user, language_code:).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
@@ -86,16 +86,25 @@ module PostRepr
end)
end
def tag_json post
post
.active_post_tags
def tag_json post, language_code:
internal_tags =
post
.post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.sort_by { TagRepr.name_for(_1.tag, language_code) }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
TagRepr.inline(post_tag.tag, language_code:).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
external_tags =
post
.external_tags
.sort_by { _1.name }
.map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) }
internal_tags + external_tags
end
def thumbnail_url post, host: nil
+12 -7
ファイルの表示
@@ -3,18 +3,23 @@
module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze
module_function
def base tag
tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
parents: tag.parents.map { _1.as_json(BASE) })
def base tag, language_code: 'ja'
tag.as_json(BASE).merge(name: name_for(tag, language_code),
aliases: tag.snapshot_aliases(language_code),
parents: tag.parents.map { inline(_1, language_code:) })
end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
def inline tag, language_code: 'ja'
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) }
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
def name_for tag, language_code
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
end
end
+15
ファイルの表示
@@ -10,6 +10,21 @@ module WikiPageRepr
wiki_page.as_json(BASE)
end
def tag_description description
tag = description.tag
{ 'id' => description.id,
'tag_name_id' => tag.tag_name('ja')&.id,
'body' => description.body,
'created_user_id' => description.created_by_user_id,
'updated_user_id' => description.updated_by_user_id,
'created_at' => description.created_at,
'updated_at' => description.updated_at,
'discarded_at' => nil,
'version_no' => description.version_no,
'title' => tag.name('ja'),
'deprecated_at' => tag.deprecated_at }
end
def many wiki_pages
wiki_pages.map { |p| base(p) }
end
+1 -1
ファイルの表示
@@ -31,7 +31,7 @@ class MaterialVersionRecorder < VersionRecorder
{ url: @record.url,
parent: @record.parent,
tag: @record.tag,
tag_name: @record.tag&.name,
tag_name: @record.tag&.name('ja'),
tag_category: @record.tag&.category,
source_kind: @record.source_kind,
source_uri: @record.source_uri,
+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
+5 -5
ファイルの表示
@@ -6,7 +6,7 @@ class PostBulkCreator
@host = host
end
def run
def run locale
results = Array.new(@posts.length)
mutex = Mutex.new
next_index = 0
@@ -27,7 +27,7 @@ class PostBulkCreator
break if index >= @posts.length
attributes = @posts[index]
results[index] = create_row(actor, attributes, index)
results[index] = create_row(locale, actor, attributes, index)
rescue StandardError => e
Rails.logger.error(
"post_bulk_creator_worker_failure #{ { error: e.class.name,
@@ -61,12 +61,12 @@ class PostBulkCreator
private
def create_row actor, attributes, index
def create_row locale, actor, attributes, index
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes),
host: @host).run
host: @host).run(locale)
if preflight[:existing_post_id].present?
return {
status: 'skipped',
@@ -76,7 +76,7 @@ class PostBulkCreator
post = PostCreator.new(
actor: actor,
attributes: normalised_attributes(attributes, preflight, index)).create!
attributes: normalised_attributes(attributes, preflight, index)).create!(locale)
result = {
status: 'created',
post: { id: post.id } }
+72 -63
ファイルの表示
@@ -8,50 +8,50 @@ class PostCreatePlan
@existing_tags_by_name = nil
end
def build!
direct_tag_specs, tag_sections = parse_direct_tag_specs
default_tag_specs = build_default_tag_specs(direct_tag_specs)
def build! locale
direct_tag_specs, tag_sections = parse_direct_tag_specs(locale)
default_tag_specs = build_default_tag_specs(locale, direct_tag_specs)
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] })
validate_new_tag_specs!(snapshot_tag_specs)
post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
preload_existing_tags_by_name!(locale, snapshot_tag_specs.map { _1[:name] })
validate_new_tag_specs!(locale, snapshot_tag_specs)
post_tag_specs = expand_parent_tag_specs(locale, snapshot_tag_specs)
video_ms = normalise_video_ms(snapshot_tag_specs)
validate_video_sections!(video_ms, tag_sections)
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
def tag_names = @attributes[:tags].to_s.split
def parse_direct_tag_specs
def parse_direct_tag_specs locale
tag_sections = { }
direct_tag_specs = []
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?
tag_name, category, sections = parse_raw_tag_name(locale, raw_name)
existing_tag = existing_tags_by_name(locale)[tag_name]
if existing_tag&.deprecated?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name(locale.language_code)]
end
direct_tag_specs << {
name: tag_name,
@@ -67,7 +67,7 @@ class PostCreatePlan
[merge_tag_specs(direct_tag_specs), tag_sections]
end
def parse_raw_tag_name raw_name
def parse_raw_tag_name locale, raw_name
name = raw_name.to_s
prefix, category =
Tag::CATEGORY_PREFIXES.find {
@@ -76,7 +76,7 @@ class PostCreatePlan
name = name.sub(/\A#{ prefix }/i, '')
sections = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
while match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
@@ -89,17 +89,17 @@ class PostCreatePlan
raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end
[resolved_tag_name(name), category&.to_sym, sections]
[resolved_tag_name(locale, name), category&.to_sym, sections]
end
def build_default_tag_specs direct_tag_specs
def build_default_tag_specs locale, direct_tag_specs
default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << {
name: TAGME_TAG_NAME,
category: :meta }
end
if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME,
category: :meta }
@@ -108,17 +108,18 @@ class PostCreatePlan
default_tag_specs
end
def validate_new_tag_specs! specs
def validate_new_tag_specs! locale, specs
Array(specs).each do |spec|
next if existing_tags_by_name.key?(spec[:name])
next if existing_tags_by_name(locale).key?(spec[:name])
validate_new_tag_spec!(spec)
validate_new_tag_spec!(locale, spec)
end
end
def validate_new_tag_spec! spec
tag_name = TagName.new(name: spec[:name])
tag = Tag.new(category: spec[:category], tag_name:)
def validate_new_tag_spec! locale, spec
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true)
tag = Tag.new(category: spec[:category])
return if tag_name.valid? && tag.valid?
post = Post.new
@@ -133,20 +134,22 @@ class PostCreatePlan
raise ActiveRecord::RecordInvalid, post
end
def expand_parent_tag_specs snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
def expand_parent_tag_specs locale, snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map do
existing_tags_by_name(locale)[_1[:name]]
end
expanded_parent_specs =
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{
name: tag.name,
category: tag.category.to_sym } }
{ name: tag.name(locale.language_code),
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,36 +158,40 @@ 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
def existing_tags_by_name locale
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
end
end
def preload_existing_tags_by_name! names
def preload_existing_tags_by_name! locale, names
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
missing_names = wanted_names - existing_tags_by_name.keys
missing_names = wanted_names - existing_tags_by_name(locale).keys
return if missing_names.empty?
existing_tags_by_name.merge!(
Tag.joins(:tag_name)
.where(tag_names: { name: missing_names })
.index_by(&:name))
existing_tags_by_name(locale).merge!(
TagName.where(language_code: locale.language_code,
primary_flg: true,
name: missing_names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag })
end
def canonical_tag_name_without_sections raw_name
name, = parse_raw_tag_name(raw_name)
def canonical_tag_name_without_sections locale, raw_name
name, = parse_raw_tag_name(locale, raw_name)
name
end
def deerjikist_tag_spec? spec
def deerjikist_tag_spec? locale, spec
return true if spec[:category] == :deerjikist
existing_tags_by_name[spec[:name]]&.deerjikist?
existing_tags_by_name(locale)[spec[:name]]&.deerjikist?
end
def normalise_parent_post_ids
@@ -262,10 +269,12 @@ class PostCreatePlan
end
end
def resolved_tag_name name
tag_name = TagName.includes(:canonical).find_by(name:)
return name if tag_name.nil?
def resolved_tag_name locale, name
tag_name = TagName.find_by(language_code: locale.language_code, name:)
return name if !(tag_name) || tag_name.primary?
(tag_name.canonical || tag_name).name
TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end
end
+3 -2
ファイルの表示
@@ -15,9 +15,10 @@ class PostCreatePreflight
@host = host
end
def run
def run locale
preview = PostImportPreviewer.new.preview_rows(
rows: [preview_row],
locale:,
fetch_metadata: false).first
if preview[:existing_post_id].present?
return {
@@ -53,7 +54,7 @@ class PostCreatePreflight
original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'] }).build!
video_ms: preview[:attributes]['video_ms'] }).build!(locale)
{
url: plan[:url],
+40 -32
ファイルの表示
@@ -9,7 +9,7 @@ class PostCreator
@field_warnings = { }
end
def create!
def create! locale
thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url],
@@ -21,14 +21,17 @@ class PostCreator
ApplicationRecord.transaction do
post.save!
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
snapshot_tags = planned_snapshot_tags
post_tags = planned_post_tags
sections = planned_sections
TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
post.video_ms = planned_video_ms
snapshot_tags = planned_snapshot_tags(locale)
post_tags = planned_post_tags(locale)
sections = planned_sections(locale)
TagVersioning.record_tag_snapshots!(
snapshot_tags,
created_by_user: @actor,
language_code: locale.language_code)
post.video_ms = planned_video_ms(locale)
post.save!
sync_post_tags!(post, post_tags, sections)
sync_parent_posts!(post, planned_parent_post_ids)
sync_parent_posts!(post, planned_parent_post_ids(locale))
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end
post
@@ -45,69 +48,73 @@ class PostCreator
thumbnail_base: @attributes[:thumbnail_base].presence)
end
def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
def planned_snapshot_tags(locale) = planned_create_attributes(locale)[:snapshot_tags]
def planned_post_tags = planned_create_attributes[:post_tags]
def planned_post_tags(locale) = planned_create_attributes(locale)[:post_tags]
def planned_sections = planned_create_attributes[:tag_sections]
def planned_sections(locale) = planned_create_attributes(locale)[:tag_sections]
def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
def planned_video_ms
planned_create_attributes[:video_ms]
def planned_parent_post_ids locale
planned_create_attributes(locale)[:normalised_parent_post_ids]
end
def planned_create_attributes
def planned_video_ms locale
planned_create_attributes(locale)[:video_ms]
end
def planned_create_attributes locale
@planned_create_attributes ||= begin
if @attributes.key?(:snapshot_tag_specs)
snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
@attributes[:tag_sections] || { },
snapshot_tags,
post_tags),
post_tags,
locale),
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] }
else
build_materialised_plan
build_materialised_plan(locale)
end
end
end
def build_materialised_plan
plan = PostCreatePlan.new(attributes: @attributes).build!
snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || [])
post_tags = materialise_tags(plan[:post_tag_specs] || [])
def build_materialised_plan locale
plan = PostCreatePlan.new(attributes: @attributes).build!(locale)
snapshot_tags = materialise_tags(locale, plan[:snapshot_tag_specs] || [])
post_tags = materialise_tags(locale, plan[:post_tag_specs] || [])
{
snapshot_tags: snapshot_tags,
post_tags: post_tags,
tag_sections: materialise_sections(
plan[:tag_sections] || { },
snapshot_tags,
post_tags),
post_tags,
locale),
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
video_ms: plan[:video_ms] }
end
def materialise_tags specs
def materialise_tags locale, specs
Array(specs).each_with_object({ }) do |spec, tags|
name = spec[:name] || spec['name']
category = spec[:category] || spec['category']
next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(name, category:)
tag = Tag.find_or_create_by_tag_name!(locale, name, category:)
tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag
end.values
end
def materialise_sections sections_by_name, snapshot_tags, post_tags
tags_by_name = post_tags.index_by(&:name)
def materialise_sections sections_by_name, snapshot_tags, post_tags, locale = Locale.nipponese
tags_by_name = post_tags.index_by { _1.name(locale.language_code) }
snapshot_tags.each do |tag|
tags_by_name[tag.name] ||= tag
tags_by_name[tag.name(locale.language_code)] ||= tag
end
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
@@ -121,9 +128,11 @@ class PostCreator
def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
@@ -133,10 +142,9 @@ class PostCreator
end_ms:)
end
end
PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
post_tag.discard_by!(@actor)
end
tag_id: (current_ids - desired_ids).to_a).destroy_all
end
def sync_parent_posts! post, ids
+20 -16
ファイルの表示
@@ -16,7 +16,7 @@ class PostImportPreviewer
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
def preview_rows rows:, locale: Locale.nipponese, fetch_metadata: true, metadata_cache: { }
prepared_rows = rows.map { prepare_row(_1) }
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
existing_posts =
@@ -30,13 +30,14 @@ class PostImportPreviewer
url_counts)
known_tags =
preload_known_tags(
locale,
prepared_rows,
fetch_metadata,
metadata_cache,
existing_posts,
url_counts)
prepared_rows.map { |row|
preview_row(row,
preview_row(locale, row,
fetch_metadata:,
metadata_cache:,
existing_posts:,
@@ -59,7 +60,7 @@ class PostImportPreviewer
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
end
def preview_row row,
def preview_row locale, row,
fetch_metadata:,
metadata_cache:,
existing_posts:,
@@ -124,7 +125,8 @@ class PostImportPreviewer
attributes['url'] = url
validate_basic_data(attributes, validation_errors)
validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
validate_preview_tags(locale,
merged_tags(tag_sources, provenance['tags']),
validation_errors,
known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
@@ -346,7 +348,7 @@ class PostImportPreviewer
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end
def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
def preload_known_tags locale, prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
names = prepared_rows.flat_map { |row|
attributes = initial_attributes(row)
provenance = initial_provenance(row)
@@ -358,15 +360,13 @@ class PostImportPreviewer
metadata = metadata_for(row[:normal_url], metadata_cache)
apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
end
preview_tag_names(merged_tags(tag_sources, provenance['tags']))
preview_tag_names(locale, merged_tags(tag_sources, provenance['tags']))
}.compact.uniq
return { } if names.empty?
Tag.joins(:tag_name)
.where(tag_names: { name: names })
.includes(:tag_name)
.to_a
.index_by(&:name)
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
end
def preload_parent_ids prepared_rows
@@ -437,19 +437,21 @@ class PostImportPreviewer
sources['automatic'].to_s
end
def preview_tag_names raw
def preview_tag_names locale, raw
names = raw.to_s.split
return [] if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
return []
end
names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
names.map { |name|
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
}
rescue Tag::SectionLiteralParseError
[]
end
def validate_preview_tags raw, errors, known_tags
def validate_preview_tags locale, raw, errors, known_tags
names = raw.to_s.split
return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') }
@@ -457,9 +459,11 @@ class PostImportPreviewer
return
end
parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
parsed = names.map { |name|
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
}
existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map(&:name)
deprecated = existing.select(&:deprecated?).map { _1.name(locale.language_code) }
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.']
+3 -3
ファイルの表示
@@ -47,9 +47,9 @@ class PostMetadataFetcher
existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: names })
.index_by(&:name)
.joins(:tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names })
.index_by { _1.name('ja') }
names.map { |name|
tag = existing_tags[name]
+56
ファイルの表示
@@ -0,0 +1,56 @@
class TagDescriptionOperation
LANGUAGE_CODE = 'ja'
def self.create!(tag:, body:, created_by_user:, message: nil)
ApplicationRecord.transaction do
tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: LANGUAGE_CODE)
page = Wiki::CompatibilityWriter.create!(
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
revision = page.wiki_revisions.order(id: :desc).first
record_version!(tag:, revision:, body:, user: created_by_user,
description: nil)
page
end
end
def self.update!(tag:, body:, updated_by_user:, message: nil,
base_revision_id: nil)
ApplicationRecord.transaction do
tag.lock!
description = TagDescription.lock.find_by(
tag_id: tag.id, language_code: LANGUAGE_CODE)
description&.lock!
tag_name = tag.tag_name(LANGUAGE_CODE)
page = WikiPage.lock.find_by!(tag_name:)
revision = Wiki::CompatibilityWriter.update!(
page:, body:, updated_by_user:, message:,
base_revision_id:)
next unless revision
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
revision
end
end
def self.record_version! tag:, revision:, body:, user:, description:
version_no = description ? description.version_no + 1 : 1
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
version_no:, event_type: description ? 'update' : 'create', body:,
created_by_user_id: user.id, created_at: revision.created_at)
if description
description.update!(body:, version_no:, updated_by_user_id: user.id)
else
TagDescription.create!(
id: revision.wiki_page_id, tag_id: tag.id,
language_code: LANGUAGE_CODE, version_no:, body:,
created_by_user_id: user.id, updated_by_user_id: user.id)
end
end
private_class_method :record_version!
end
+22 -3
ファイルの表示
@@ -1,5 +1,5 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:
def self.record! tag:, event_type:, created_by_user:, **_options
new(tag:, event_type:, created_by_user:).record!
end
@@ -14,10 +14,29 @@ class TagVersionRecorder < VersionRecorder
def record_key = :tag
def snapshot_attributes
{ name: @record.name,
names = snapshot_names
compatibility_names = names.fetch('ja')
{ name: compatibility_names.fetch('name'),
category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: @record.snapshot_aliases.join(' '),
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
names:,
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
def snapshot_names
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
.transform_values do |tag_names|
primary = tag_names.find(&:primary?)
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
{ 'name' => tag_name.name, 'script_code' => tag_name.script_code }
}
{ 'name' => primary.name,
'script_code' => primary.script_code,
'auto_generated' => primary.auto_generated,
'aliases' => aliases }
end
end
end
+10 -26
ファイルの表示
@@ -1,38 +1,22 @@
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
def self.record! tag, event_type:, created_by_user:, language_code: 'ja'
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
end
def self.ensure_snapshot! tag, created_by_user:
if tag.nico?
return if tag.nico_tag_versions.exists?
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
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:, language_code:)
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
record!(tag, event_type:, created_by_user:)
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:, language_code:)
end
def self.record_tag_snapshots! tags, created_by_user:
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
tags.each do |tag|
record_tag_snapshot!(tag, created_by_user:)
record_tag_snapshot!(tag, created_by_user:, language_code:)
end
end
end
+3 -3
ファイルの表示
@@ -41,7 +41,7 @@ class TheatrePostSelector
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts = eligible_posts.includes(tags: :tag_names).to_a
posts.map do |post|
post_tags = post.tags.to_a
@@ -82,7 +82,7 @@ class TheatrePostSelector
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tags = Tag.where(id: tag_penalties.keys).includes(:tag_names).index_by(&:id)
tag_penalties
.map { |tag_id, penalty|
@@ -113,7 +113,7 @@ class TheatrePostSelector
def light_tag_json tag
{ id: tag.id,
name: tag.name,
name: tag.name('ja'),
category: tag.category }
end
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 }, " +
+23 -4
ファイルの表示
@@ -8,6 +8,15 @@ module Wiki
end
def self.create_content! tag_name:, body:, created_by_user:, message: nil
if tag_name.tag && tag_name.language_code == 'ja'
return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:)
end
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)
page = WikiPage.new(tag_name:,
@@ -33,6 +42,18 @@ module Wiki
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
tag = page.tag_name.tag
if tag && page.tag_name.language_code == 'ja'
return TagDescriptionOperation.update!(
tag:, body:, updated_by_user: created_user,
message:, base_revision_id:)
end
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
end
def self.legacy_content! page:, body:, created_user:, message: nil,
base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:)
end
@@ -57,9 +78,6 @@ module Wiki
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
ActiveRecord::Base.transaction do
@page.lock!
@@ -72,6 +90,8 @@ module Wiki
end
end
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
@page.update!(body: normalised)
WikiVersionRecorder.record!(
@@ -94,7 +114,6 @@ module Wiki
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end
WikiRevisionLine.insert_all!(rows) if rows.any?
rev
end
end
+33
ファイルの表示
@@ -0,0 +1,33 @@
module Wiki
class CompatibilityWriter
def self.create! tag_name:, body:, created_by_user:, message: nil
Commit.create_legacy_content!(
tag_name:, body:, created_by_user:, message:)
end
def self.update! page:, body:, updated_by_user:, message: nil,
base_revision_id: nil
normalised = Commit.normalise_body(body)
if normalised.blank?
return Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
if base_revision_id.present?
current_id = page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Commit::Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
return if Commit.normalise_body(page.body) == normalised
Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
end
end
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
end
end
+10 -3
ファイルの表示
@@ -1,5 +1,13 @@
Rails.application.routes.draw do
resources :nico_tags, path: 'tags/nico', only: [:index, :update]
get 'tags/name/:name/description', to: 'tag_descriptions#show_by_title'
scope 'tags/:id/description', controller: :tag_descriptions do
get '', action: :show
post '', action: :create
put '', action: :update
end
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
scope 'tags/:parent_id/children', controller: :tag_children do
post ':child_id', action: :create
@@ -35,7 +43,7 @@ Rails.application.routes.draw do
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
collection do
get :search
get :search, action: :index
get :changes
scope :title do
@@ -55,7 +63,6 @@ Rails.application.routes.draw do
get :metadata
post :bulk
get :random
get :changes
get :versions, to: 'post_versions#index'
end
+53
ファイルの表示
@@ -0,0 +1,53 @@
class DeleteInactiveRecordsFromPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
post_tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :post_tags, [:tag_id, :discarded_at]
remove_index :post_tags, [:post_id, :discarded_at]
remove_index :post_tags, name: 'idx_post_tags_active_unique'
remove_index :post_tags, :discarded_at
remove_foreign_key :post_tags, column: :deleted_user_id
remove_index :post_tags, :deleted_user_id
remove_column :post_tags, :active_unique_key
remove_column :post_tags, :is_active
remove_column :post_tags, :discarded_at
remove_column :post_tags, :deleted_user_id
remove_column :post_tags, :updated_at
execute <<~SQL
ALTER TABLE
post_tags
MODIFY COLUMN
id BIGINT NOT NULL
SQL
execute <<~SQL
ALTER TABLE
post_tags
DROP PRIMARY KEY
SQL
remove_column :post_tags, :id
execute <<~SQL
ALTER TABLE
post_tags
ADD PRIMARY KEY
(post_id, tag_id)
SQL
remove_index :post_tags, :post_id
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
@@ -0,0 +1,11 @@
class AddForeignKeyOnPostIdAndTagIdInPostTagSections < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :post_tag_sections, :posts, column: :post_id
remove_foreign_key :post_tag_sections, :tags, column: :tag_id
add_foreign_key :post_tag_sections, :post_tags,
column: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
on_delete: :cascade
end
end
+46
ファイルの表示
@@ -0,0 +1,46 @@
class DeleteDiscardedRecordsFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tag_versions, :tags, column: :tag_id
remove_foreign_key :nico_tag_versions, :tags, column: :tag_id
remove_foreign_key :material_versions, :tags, column: :tag_id
execute <<~SQL
DELETE
ntr
FROM
nico_tag_relations ntr
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ntr.tag_id, ntr.nico_tag_id)
SQL
execute <<~SQL
DELETE
ti
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ti.tag_id, ti.parent_tag_id)
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tags, :discarded_at
remove_column :tags, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+18
ファイルの表示
@@ -0,0 +1,18 @@
class DeleteDiscardedRecordsFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tag_names, :discarded_at
remove_column :tag_names, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+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
ファイルの表示
@@ -0,0 +1,20 @@
class CreateLanguages < ActiveRecord::Migration[8.0]
def up
create_table :languages, id: { type: :string, limit: 16 }, primary_key: :code do |t|
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
languages(code, name, created_at)
VALUES
('ja', '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :languages
end
end
+23
ファイルの表示
@@ -0,0 +1,23 @@
class CreateScripts < ActiveRecord::Migration[8.0]
def up
create_table :scripts, id: { type: 'CHAR(4)' }, primary_key: :code do |t|
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.check_constraint "REGEXP_LIKE(code, '^[A-Z][a-z]{3}$', 'c')",
name: 'scripts_code_check'
end
execute <<~SQL
INSERT INTO
scripts(code, name, created_at)
VALUES
('Jpan', '漢字、仮名文字およびラテン文字', #{ connection.quote Time.current })
SQL
end
def down
drop_table :scripts
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
class CreateRegions < ActiveRecord::Migration[8.0]
def up
create_table :regions, id: { type: :integer, limit: 2 }, primary_key: :code do |t|
t.column :alpha_code, 'CHAR(2)'
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.check_constraint "REGEXP_LIKE(alpha_code, '^[A-Z]{2}$', 'c')",
name: 'regions_alpha_code_check'
t.index [:alpha_code], unique: true
end
execute <<~SQL
INSERT INTO
regions(code, alpha_code, name, created_at)
VALUES
(392, 'JP', '日本国', #{ connection.quote Time.current })
SQL
end
def down
drop_table :regions
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
class CreateLocales < ActiveRecord::Migration[8.0]
def up
create_table :locales, id: { type: :string, limit: 32 }, primary_key: :code do |t|
t.string :language_code, limit: 16, null: false, index: true
t.column :script_code, 'CHAR(4)', null: false
t.integer :region_code, limit: 2, null: false
t.string :variants, limit: 255, null: false, default: ''
t.string :name, null: false
t.datetime :deprecated_at, index: true
t.datetime :created_at, null: false
t.foreign_key :languages, column: :language_code, primary_key: :code
t.foreign_key :scripts, column: :script_code, primary_key: :code
t.foreign_key :regions, column: :region_code, primary_key: :code
t.check_constraint ('REGEXP_LIKE(variants, ' +
"'^(" +
'(([a-z0-9]{5,8}|[0-9][a-z0-9]{3})' +
'(-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*' +
'(-x(-[a-z0-9]{1,8})+)?)' +
'|(x(-[a-z0-9]{1,8})+)' +
")?$', 'c')"),
name: 'locales_variants_check'
t.index [:language_code, :script_code, :region_code, :variants], unique: true
end
execute <<~SQL
INSERT INTO
locales(code, language_code, script_code, region_code, name, created_at)
VALUES
('ja', 'ja', 'Jpan', 392, '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :locales
end
end
+69
ファイルの表示
@@ -0,0 +1,69 @@
class AddColumnsToTagNames < ActiveRecord::Migration[8.0]
def up
add_reference :tag_names, :tag, after: :id, foreign_key: true
add_column :tag_names, :language_code, :string,
limit: 16, null: false, after: :tag_id, default: 'ja'
add_column :tag_names, :script_code, 'CHAR(4)',
null: false, after: :name, default: 'Jpan'
add_column :tag_names, :primary_flg, :boolean,
null: false, after: :script_code, default: true
add_column :tag_names, :auto_generated, :boolean,
null: false, default: false, after: :primary_flg
add_foreign_key :tag_names, :languages, column: :language_code, primary_key: :code
add_foreign_key :tag_names, :scripts, column: :script_code, primary_key: :code
remove_index :tag_names, :name
add_index :tag_names, [:language_code, :name], unique: true
change_column_default :tag_names, :language_code, from: 'ja', to: nil
change_column_default :tag_names, :script_code, from: 'Jpan', to: nil
execute <<~SQL
UPDATE
tag_names tn
LEFT JOIN
tags AS t
ON
t.tag_name_id = COALESCE(tn.canonical_id, tn.id)
SET
tn.tag_id = t.id
, tn.primary_flg = CASE
WHEN tn.canonical_id IS NULL THEN
1
ELSE
0
END
SQL
change_column_default :tag_names, :primary_flg, from: true, to: nil
add_column :tag_names, :primary_tag_id, :bigint,
as: 'CASE WHEN primary_flg THEN tag_id ELSE NULL END'
add_index :tag_names, [:primary_tag_id, :language_code], unique: true
add_check_constraint :tag_names,
'primary_flg OR (NOT auto_generated)',
name: 'chk_tag_names_auto_generated_primary'
end
def down
remove_check_constraint :tag_names,
name: 'chk_tag_names_auto_generated_primary'
remove_index :tag_names, [:primary_tag_id, :language_code]
remove_column :tag_names, :primary_tag_id
remove_foreign_key :tag_names, column: :language_code
remove_foreign_key :tag_names, column: :script_code
remove_index :tag_names, [:language_code, :name]
add_index :tag_names, :name, unique: true
remove_column :tag_names, :auto_generated
remove_column :tag_names, :primary_flg
remove_column :tag_names, :script_code
remove_column :tag_names, :language_code
remove_reference :tag_names, :tag, foreign_key: true
end
end
+68
ファイルの表示
@@ -0,0 +1,68 @@
class AddNamesToTagVersions < ActiveRecord::Migration[8.0]
class TagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
def up
add_column :tag_versions, :names, :json
TagVersion.reset_column_information
say_with_time 'Backfilling multilingual tag version names' do
TagVersion.find_each do |version|
aliases = version.aliases.to_s.split.filter_map do |name|
name.present? && { name:, script_code: 'Jpan' }
end
names = { ja: { name: version.name,
script_code: 'Jpan',
auto_generated: false,
aliases: } }
version.update_columns(names:)
end
end
add_names_constraint!
change_column_null :tag_versions, :names, false
end
def down
remove_check_constraint :tag_versions,
name: 'chk_tag_versions_names_schema'
remove_column :tag_versions, :names
end
private
def add_names_constraint!
schema = { type: 'object',
minProperties: 1,
patternProperties: { '^.{1,16}$': name_schema },
additionalProperties: false }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :tag_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, names)",
name: 'chk_tag_versions_names_schema'
end
def name_schema
{ type: 'object',
required: ['name', 'script_code', 'auto_generated', 'aliases'],
properties: { **name_schema_base,
auto_generated: { type: 'boolean' },
aliases: { type: 'array',
uniqueItems: true,
items: { type: 'object',
required: ['name', 'script_code'],
properties: name_schema_base,
additionalProperties: false } } },
additionalProperties: false }
end
def name_schema_base
{ name: { type: 'string', minLength: 1, maxLength: 255 },
script_code: { type: 'string', pattern: '^[A-Z][a-z]{3}$' } }
end
end
+63
ファイルの表示
@@ -0,0 +1,63 @@
class CreateTagDescriptions < ActiveRecord::Migration[8.0]
def change
create_table :tag_description_versions do |t|
t.references :tag, null: false, foreign_key: false
t.string :language_code, limit: 16, null: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.text :body, null: false
t.string :source_language_code, limit: 16
t.integer :source_version_no
t.references :created_by_user
t.datetime :created_at, null: false
t.index [:tag_id, :language_code, :version_no], unique: true
t.foreign_key :tag_description_versions,
column: [:tag_id, :source_language_code, :source_version_no],
primary_key: [:tag_id, :language_code, :version_no]
t.check_constraint 'version_no > 0',
name: 'chk_tag_description_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'tag_description_versions_event_type_valid'
t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' +
'(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'),
name: 'chk_tag_description_versions_source_pair'
t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code',
name: 'chk_tag_description_versions_source_language_code_different'
end
create_table :tag_descriptions do |t|
t.references :tag, null: false, foreign_key: true
t.string :language_code, limit: 16, null: false, index: true
t.integer :version_no, null: false
t.text :body, null: false
t.string :source_language_code, limit: 16
t.integer :source_version_no
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.foreign_key :languages, column: :language_code, primary_key: :code
t.foreign_key :tag_description_versions,
column: [:tag_id, :source_language_code, :source_version_no],
primary_key: [:tag_id, :language_code, :version_no]
t.index [:tag_id, :language_code], unique: true
t.index [:source_language_code, :source_version_no]
t.check_constraint 'version_no > 0',
name: 'chk_tag_descriptions_version_no_positive'
t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' +
'(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'),
name: 'chk_tag_descriptions_source_pair'
t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code',
name: 'chk_tag_descriptions_source_language_code_different'
end
end
end
+104
ファイルの表示
@@ -0,0 +1,104 @@
class MigrateTagDescriptionsFromWikiPages < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiRevision < ActiveRecord::Base
self.table_name = 'wiki_revisions'
end
class WikiRevisionLine < ActiveRecord::Base
self.table_name = 'wiki_revision_lines'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class TagDescription < ActiveRecord::Base
self.table_name = 'tag_descriptions'
end
class TagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
def up
WikiPage.find_each do |page|
tag_name = TagName.find_by(id: page.tag_name_id, language_code: 'ja')
next unless tag_name&.tag_id
revisions = WikiRevision.where(wiki_page_id: page.id).order(:id).to_a
revisions.each_with_index do |revision, index|
version_no = index + 1
tag_id = tag_name[:tag_id]
unless tag_id
raise (
'tag_id is nil: ' +
"wiki_page_id=#{ page.id }, " +
"tag_name_id=#{ tag_name.id }, " +
"tag_name=#{ tag_name.attributes.inspect}")
end
revision_id = revision.id
say (
"wiki_page_id=#{ page.id }, " +
"tag_name_id=#{ tag_name.id }, " +
"tag_id=#{ tag_id }, " +
"revision_id=#{ revision_id }")
version = TagDescriptionVersion.new(
id: revision_id,
tag_id:,
language_code: 'ja',
version_no:,
event_type: version_no == 1 ? 'create' : 'update',
body: revision_body(revision_id),
source_language_code: nil,
source_version_no: nil,
created_by_user_id: revision.created_user_id,
created_at: revision.created_at)
say version.attributes.inspect
version.save!
end
next if page.discarded_at.present?
next if revisions.empty?
TagDescription.create!(
id: page.id,
tag_id: tag_name.tag_id,
language_code: 'ja',
version_no: revisions.length,
body: page.body,
source_language_code: nil,
source_version_no: nil,
created_by_user_id: page.created_user_id,
updated_by_user_id: page.updated_user_id,
created_at: page.created_at,
updated_at: page.updated_at)
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def revision_body revision_id
WikiRevisionLine
.joins('INNER JOIN wiki_lines ' +
'ON wiki_lines.id = wiki_revision_lines.wiki_line_id')
.where(wiki_revision_id: revision_id)
.order(:position)
.pluck('wiki_lines.body')
.join("\n")
end
end
生成ファイル
+127 -38
ファイルの表示
@@ -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_07_27_123600) do
ActiveRecord::Schema[8.0].define(version: 2026_10_03_010000) 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_07_27_123600) 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
@@ -130,6 +138,29 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end
create_table "languages", primary_key: "code", id: { type: :string, limit: 16 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["deprecated_at"], name: "index_languages_on_deprecated_at"
end
create_table "locales", primary_key: "code", id: { type: :string, limit: 32 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "language_code", limit: 16, null: false
t.string "script_code", limit: 4, null: false
t.integer "region_code", limit: 2, null: false
t.string "variants", default: "", null: false
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["deprecated_at"], name: "index_locales_on_deprecated_at"
t.index ["language_code", "script_code", "region_code", "variants"], name: "idx_on_language_code_script_code_region_code_varian_b0ba85c39a", unique: true
t.index ["language_code"], name: "index_locales_on_language_code"
t.index ["region_code"], name: "fk_rails_175159992c"
t.index ["script_code"], name: "fk_rails_0b74ce96a8"
t.check_constraint "regexp_like(`variants`,_utf8mb4'^((([a-z0-9]{5,8}|[0-9][a-z0-9]{3})(-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(-x(-[a-z0-9]{1,8})+)?)|(x(-[a-z0-9]{1,8})+))?$',_utf8mb4'c')", name: "locales_variants_check"
end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false
@@ -281,6 +312,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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
@@ -311,23 +349,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
t.index ["post_id"], name: "index_post_tags_on_post_id"
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end
@@ -363,7 +390,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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|
@@ -384,6 +411,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end
create_table "regions", primary_key: "code", id: { type: :integer, limit: 2 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "alpha_code", limit: 2
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["alpha_code"], name: "index_regions_on_alpha_code", unique: true
t.index ["deprecated_at"], name: "index_regions_on_deprecated_at"
t.check_constraint "regexp_like(`alpha_code`,_utf8mb4'^[A-Z]{2}$',_utf8mb4'c')", name: "regions_alpha_code_check"
end
create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at"
t.check_constraint "regexp_like(`code`,_utf8mb4'^[A-Z][a-z]{3}$',_utf8mb4'c')", name: "scripts_code_check"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.datetime "created_at", null: false
@@ -395,17 +440,47 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
create_table "tag_description_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.string "language_code", limit: 16, null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_description_versions_source_pair"
t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_description_versions_source_language_code_different"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "tag_description_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "chk_tag_description_versions_version_no_positive"
end
create_table "tag_descriptions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.string "language_code", limit: 16, null: false
t.integer "version_no", null: false
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
t.index ["created_by_user_id"], name: "index_tag_descriptions_on_created_by_user_id"
t.index ["language_code"], name: "index_tag_descriptions_on_language_code"
t.index ["source_language_code", "source_version_no"], name: "idx_on_source_language_code_source_version_no_00117fe02a"
t.index ["tag_id", "language_code"], name: "index_tag_descriptions_on_tag_id_and_language_code", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_d1be0a7696"
t.index ["tag_id"], name: "index_tag_descriptions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_tag_descriptions_on_updated_by_user_id"
t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_descriptions_source_pair"
t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_descriptions_source_language_code_different"
t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive"
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -429,14 +504,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id"
t.string "language_code", limit: 16, null: false
t.string "name", null: false
t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false
t.boolean "auto_generated", default: false, null: false
t.bigint "canonical_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["name"], name: "index_tag_names_on_name", unique: true
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
t.index ["tag_id"], name: "index_tag_names_on_tag_id"
t.check_constraint "(0 <> `primary_flg`) or (0 = `auto_generated`)", name: "chk_tag_names_auto_generated_primary"
end
create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -458,11 +541,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.datetime "deprecated_at"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.json "names", null: false
t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"object\",\"minProperties\":1,\"patternProperties\":{\"^.{1,16}$\":{\"type\":\"object\",\"required\":[\"name\",\"script_code\",\"auto_generated\",\"aliases\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":255},\"script_code\":{\"type\":\"string\",\"pattern\":\"^[A-Z][a-z]{3}$\"},\"auto_generated\":{\"type\":\"boolean\"},\"aliases\":{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"type\":\"object\",\"required\":[\"name\",\"script_code\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":255},\"script_code\":{\"type\":\"string\",\"pattern\":\"^[A-Z][a-z]{3}$\"}},\"additionalProperties\":false}}},\"additionalProperties\":false}},\"additionalProperties\":false}',`names`)", name: "chk_tag_versions_names_schema"
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -472,10 +557,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -632,7 +715,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
@@ -701,6 +783,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id"
add_foreign_key "locales", "languages", column: "language_code", primary_key: "code"
add_foreign_key "locales", "regions", column: "region_code", primary_key: "code"
add_foreign_key "locales", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
@@ -709,37 +794,43 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
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", "tags"
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"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tag_sections", "post_tags", column: ["post_id", "tag_id"], primary_key: ["post_id", "tag_id"], on_delete: :cascade
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users"
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "tags"
add_foreign_key "tag_descriptions", "users", column: "created_by_user_id"
add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id"
add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
@@ -766,8 +857,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+58 -33
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -30,9 +30,28 @@ namespace :nico do
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
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,64 @@ 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.nico.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)
name = TagNameSanitisationRule.sanitise("nico:#{ raw }").delete_prefix('nico:')
tag = ExternalTag.find_or_create_by!(platform: :nico, name:)
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
+124
ファイルの表示
@@ -0,0 +1,124 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921020000_delete_discarded_records_from_tags'
require_relative '../../db/migrate/20260921030000_delete_discarded_records_from_tag_names'
RSpec.describe 'discarded tag cleanup migrations' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |migration_class|
it "rejects rollback of #{ migration_class.name }" do
expect { migration_class.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
end
context 'with legacy records' do
# Reproduce the historical schema required by these pre-multilingual migrations.
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('TagCleanupMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_tag_cleanup_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
end
after do
@connection.drop_database(@database) if @database_created
ensure
TagCleanupMigrationRecord.remove_connection
end
before do
@connection.create_table(:tag_names) do |t|
t.string :name, null: false, index: { unique: true }
t.bigint :canonical_id
t.datetime :discarded_at, index: true
end
@connection.add_foreign_key(:tag_names, :tag_names, column: :canonical_id)
@connection.create_table(:tags) do |t|
t.references :tag_name, null: false, foreign_key: true, index: { unique: true }
t.datetime :discarded_at, index: true
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, null: false, foreign_key: true
t.references :nico_tag, null: false, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, null: false, foreign_key: true
t.references :parent_tag, null: false, foreign_key: { to_table: :tags }
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
@connection.create_table(table) do |t|
t.references :tag, null: false, foreign_key: true
end
end
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name, canonical_id, discarded_at) VALUES
(1, 'kept', NULL, NULL),
(2, 'merged_alias', 1, NULL),
(3, 'nico:deleted', NULL, '2026-09-20'),
(4, 'nico:kept', NULL, NULL),
(5, 'deleted_name', NULL, '2026-09-20')
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, discarded_at) VALUES
(1, 1, NULL), (2, 2, '2026-09-20'),
(3, 3, '2026-09-20'), (4, 4, NULL)
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_relations (id, tag_id, nico_tag_id) VALUES
(1, 1, 4), (2, 2, 4), (3, 1, 3), (4, 2, 3)
SQL
@connection.execute(<<~SQL)
INSERT INTO tag_implications (id, tag_id, parent_tag_id) VALUES
(1, 1, 4), (2, 2, 1), (3, 1, 2), (4, 2, 3)
SQL
@connection.execute('INSERT INTO tag_versions (tag_id) VALUES (1), (2)')
@connection.execute('INSERT INTO nico_tag_versions (tag_id) VALUES (3), (4)')
@connection.execute('INSERT INTO material_versions (tag_id) VALUES (1), (2)')
end
it 'removes discarded records and their links while retaining aliases and history' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |klass|
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.up }
end
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 4])
expect(@connection.select_rows('SELECT id, canonical_id FROM tag_names ORDER BY id'))
.to eq([[1, nil], [2, 1], [4, nil]])
expect(@connection.select_values('SELECT id FROM nico_tag_relations')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_implications')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM tag_versions ORDER BY tag_id'))
.to eq([1, 2])
expect(@connection.select_values('SELECT tag_id FROM nico_tag_versions ORDER BY tag_id'))
.to eq([3, 4])
expect(@connection.select_values('SELECT tag_id FROM material_versions ORDER BY tag_id'))
.to eq([1, 2])
[:tags, :tag_names].each do |table|
expect(@connection.column_exists?(table, :discarded_at)).to be(false)
expect(@connection.index_exists?(table, :discarded_at)).to be(false)
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
expect(@connection.foreign_key_exists?(table, :tags, column: :tag_id)).to be(false)
end
expect(@connection.foreign_key_exists?(:tags, :tag_names)).to be(true)
expect(@connection.foreign_key_exists?(:tag_names, :tag_names, column: :canonical_id))
.to be(true)
expect(@connection.index_exists?(:tag_names, :name, unique: true)).to be(true)
expect(@connection.index_exists?(:tags, :tag_name_id, unique: true)).to be(true)
[:nico_tag_relations, :tag_implications].each do |table|
expect(@connection.foreign_keys(table).map(&:to_table)).to eq(['tags', 'tags'])
end
end
end
end
+191
ファイルの表示
@@ -0,0 +1,191 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921040000_create_external_tags'
require_relative '../../db/migrate/20260921050000_create_post_external_tags'
require_relative '../../db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations'
require_relative '../../db/migrate/20260921230000_migrate_external_tags'
RSpec.describe 'external tag migrations' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('ExternalTagMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_external_tags_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
version_class = Class.new(record_class) do
self.table_name = 'post_versions'
end
stub_const('MigrateExternalTags::MigrationPostVersion', version_class)
create_legacy_tables
seed_legacy_records
end
after do
@connection.drop_database(@database) if @database_created
ensure
ExternalTagMigrationRecord.remove_connection
end
def migrate klass
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.migrate(:up) }
end
def create_legacy_tables
@connection.create_table(:tag_names) { |t| t.string :name }
@connection.create_table(:tags) do |t|
t.references :tag_name, foreign_key: true
t.string :category
t.integer :post_count
t.datetime :created_at
end
@connection.create_table(:posts)
@connection.create_table(:post_tags) do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.datetime :created_at
end
@connection.create_table(:nico_tag_versions) do |t|
t.bigint :tag_id
t.integer :version_no
t.string :name
t.datetime :created_at
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, foreign_key: true
t.references :nico_tag, foreign_key: { to_table: :tags }
end
[:post_tag_sections, :materials, :theatre_skip_event_tags].each do |table|
@connection.create_table(table) { |t| t.references :tag, foreign_key: true }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, foreign_key: true
t.references :parent_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_similarities) do |t|
t.references :tag, foreign_key: true
t.references :target_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:post_versions) { |t| t.json :tags_json, null: false }
@connection.add_check_constraint(:post_versions, 'JSON_VALID(tags_json)',
name: 'chk_post_versions_tags_json_schema')
end
def seed_legacy_records
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name) VALUES (1, 'internal'), (2, 'nico:raw tag[]');
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, category, post_count, created_at) VALUES
(1, 1, 'general', 1, '2026-09-01'), (2, 2, 'nico', 1, '2026-09-02')
SQL
@connection.execute('INSERT INTO posts (id) VALUES (1)')
@connection.execute(<<~SQL)
INSERT INTO post_tags (post_id, tag_id, created_at) VALUES
(1, 1, '2026-09-03'), (1, 2, '2026-09-04')
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_versions (tag_id, version_no, name, created_at) VALUES
(2, 1, 'nico:raw tag[]', '2026-09-02'),
(3, 1, 'nico:deleted', '2026-09-01'),
(3, 2, 'nico:deleted_later', '2026-09-02')
SQL
@connection.execute('INSERT INTO nico_tag_relations (tag_id, nico_tag_id) VALUES (1, 2)')
@connection.execute(<<~SQL)
INSERT INTO tag_similarities (tag_id, target_tag_id) VALUES (1, 1), (1, 2), (2, 1)
SQL
@connection.execute('INSERT INTO theatre_skip_event_tags (tag_id) VALUES (1), (2)')
@legacy_internal = { 'id' => 1, 'version_no' => 2,
'name' => 'internal', 'category' => 'general',
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }] }
snapshots = [@legacy_internal,
{ 'id' => 2, 'category' => 'nico' },
{ 'id' => 3, 'category' => 'nico' }]
@connection.execute(<<~SQL)
INSERT INTO post_versions (tags_json) VALUES
(#{ @connection.quote(snapshots.to_json) }), ('[]')
SQL
end
def prepare_external_tables
migrate(CreateExternalTags)
migrate(CreatePostExternalTags)
migrate(ChangeForeignKeyOnNicoTagRelations)
end
it 'preserves ids, raw names, counts, timestamps, post links and historical-only tags' do
prepare_external_tables
rows = @connection.select_rows(<<~SQL)
SELECT id, platform, name, post_count, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM external_tags ORDER BY id
SQL
expect(rows).to eq([
[2, 'nico', 'raw tag[]', 1, '2026-09-02'],
[3, 'nico', 'deleted', 0, '2026-09-01']])
expect(@connection.select_rows(<<~SQL)).to eq([[1, 2, '2026-09-04']])
SELECT post_id, external_tag_id, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM post_external_tags
SQL
expect(@connection.foreign_key_exists?(:nico_tag_relations, :external_tags,
column: :nico_tag_id)).to be(true)
expect(@connection.foreign_key_exists?(:nico_tag_relations, :tags,
column: :tag_id)).to be(true)
end
it 'converts snapshots and removes only obsolete internal rows and derived links' do
prepare_external_tables
migrate(MigrateExternalTags)
expect(@connection.select_values('SELECT id FROM tags')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_names')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM post_tags')).to eq([1])
expect(@connection.select_rows('SELECT tag_id, nico_tag_id FROM nico_tag_relations'))
.to eq([[1, 2]])
expect(@connection.select_rows('SELECT tag_id, target_tag_id FROM tag_similarities'))
.to eq([[1, 1]])
expect(@connection.select_values('SELECT tag_id FROM theatre_skip_event_tags')).to eq([1])
expect(@connection.select_value('SELECT COUNT(*) FROM nico_tag_versions')).to eq(3)
versions = MigrateExternalTags::MigrationPostVersion.order(:id)
expect(versions.first.tags_json).to eq([
@legacy_internal.except('id').merge('tag_id' => 1),
{ 'external_tag_id' => 2 }, { 'external_tag_id' => 3 }])
expect(versions.last.tags_json).to eq([])
invalid_snapshots = [@legacy_internal,
{ 'external_tag_id' => 0 },
{ 'external_tag_id' => 2, 'tag_id' => 1 },
{ 'external_tag_id' => 2, 'name' => 'extra' },
{ 'tag_id' => 1, 'version_no' => 1 }]
invalid_snapshots.each do |snapshot|
expect {
MigrateExternalTags::MigrationPostVersion.create!(tags_json: [snapshot])
}.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
expect { MigrateExternalTags.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
{ post_tag_sections: '(tag_id) VALUES (2)',
materials: '(tag_id) VALUES (2)',
tag_implications: '(tag_id, parent_tag_id) VALUES (1, 2)' }.each do |table, values|
it "aborts before deleting data when #{ table } references a legacy nico tag" do
prepare_external_tables
@connection.execute("INSERT INTO #{ table } #{ values }")
expect { migrate(MigrateExternalTags) }.to raise_error(RuntimeError, /#{ table }/)
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 2])
expect(MigrateExternalTags::MigrationPostVersion.first.tags_json.first)
.to eq(@legacy_internal)
end
end
end
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'rails_helper'
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
RSpec.describe 'Tag description migration identity' do
it 'preserves every revision ID and repeated body in an independent sequence' do
user = create_member_user!
tag_name = create(:tag_name, name: 'description_migration')
page = Wiki::Commit.create_content!(
tag_name:, body: 'A', created_by_user: user)
revisions = [page.current_revision]
['A', 'B', 'B', 'A'].each do |body|
revisions << Wiki::Commit.content!(
page:, body:, created_user: user, base_revision_id: revisions.last.id)
end
tag = create(:tag, primary_tag_name: tag_name)
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
expect(page.wiki_versions.count).to eq(3)
migration = MigrateTagDescriptionsFromWikiPages.new
migration.suppress_messages { migration.up }
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
.order(:version_no)
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
expect(TagDescription.find(page.id)).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
.to eq(legacy_state)
end
end
+36
ファイルの表示
@@ -0,0 +1,36 @@
require 'rails_helper'
RSpec.describe 'TagName database invariants' do
def insert_name!(**attributes)
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true,
created_at: Time.current, updated_at: Time.current,
**attributes }])
end
it 'defaults persisted names to human-confirmed provenance' do
insert_name!
expect(TagName.find_by!(name: 'constraint_name').auto_generated).to be(false)
end
[[true, true], [true, false], [false, false]].each do |primary_flg, auto_generated|
it "allows primary=#{ primary_flg }, auto_generated=#{ auto_generated }" do
expect { insert_name!(primary_flg:, auto_generated:) }
.to change(TagName, :count).by(1)
end
end
it 'rejects auto-generated aliases at the database boundary' do
expect { insert_name!(primary_flg: false, auto_generated: true) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects a second primary for the same tag and language' do
tag = create(:tag)
expect { insert_name!(tag_id: tag.id, auto_generated: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
end
+91
ファイルの表示
@@ -0,0 +1,91 @@
require 'rails_helper'
RSpec.describe 'TagVersion names database constraint' do
let(:tag) { create(:tag) }
let(:names) do
{ 'ja' => { 'name' => 'snapshot_name', 'script_code' => 'Jpan',
'auto_generated' => false, 'aliases' => [] } }
end
let(:alias_name) { { 'name' => 'snapshot_alias', 'script_code' => 'Jpan' } }
def insert_snapshot!(snapshot)
TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create',
name: 'snapshot_name', category: 'general',
aliases: '', parent_tag_ids: '', names: snapshot,
created_at: Time.current }])
end
it 'accepts grouped Nipponese names with an empty alias list' do
expect { insert_snapshot!(names) }.to change(TagVersion, :count).by(1)
expect(TagVersion.find_by!(tag_id: tag.id).names).to eq(names)
end
it 'accepts aliases containing only their name and script' do
names['ja']['aliases'] = [alias_name]
expect { insert_snapshot!(names) }.to change(TagVersion, :count).by(1)
end
it 'rejects a flat array root' do
expect { insert_snapshot!([names.fetch('ja')]) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects an empty object root' do
expect { insert_snapshot!({ }) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects a language entry that is not an object' do
expect { insert_snapshot!({ 'ja' => 'not an object' }) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
['name', 'script_code', 'auto_generated', 'aliases'].each do |field|
it "rejects a primary entry missing #{ field }" do
names['ja'].delete(field)
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
it 'rejects an empty primary name' do
names['ja']['name'] = ''
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
it 'rejects incorrect script casing' do
names['ja']['script_code'] = 'JPAN'
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
['unknown', 'primary_flg', 'language_code'].each do |field|
it "rejects the extra primary field #{ field }" do
names['ja'][field] = true
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
['unknown', 'primary_flg', 'auto_generated'].each do |field|
it "rejects the extra alias field #{ field }" do
names['ja']['aliases'] = [alias_name.merge(field => true)]
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
it 'rejects duplicate alias objects' do
names['ja']['aliases'] = [alias_name, alias_name.deep_dup]
expect { insert_snapshot!(names) }
.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
+9 -1
ファイルの表示
@@ -1,5 +1,13 @@
FactoryBot.define do
factory :tag_name do
name { "tag-#{SecureRandom.hex(4)}" }
language_code { 'ja' }
script_code { 'Jpan' }
primary_flg { true }
auto_generated { false }
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
trait :alias do
primary_flg { false }
end
end
end
+26 -7
ファイルの表示
@@ -1,21 +1,40 @@
FactoryBot.define do
factory :tag do
transient do
name { nil }
primary_name { nil }
primary_tag_name do
attributes = primary_name.nil? ? { } : { name: primary_name }
build(:tag_name, **attributes)
end
end
category { :general }
post_count { 0 }
association :tag_name
after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present?
evaluator.primary_tag_name.primary_flg = true
tag.tag_names.target << evaluator.primary_tag_name
end
trait :nico do
category { :nico }
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
before(:create) do |tag, evaluator|
evaluator.primary_tag_name.save!
# Compatibility write only; ownership is established through tag_id below.
tag.tag_name_id = evaluator.primary_tag_name.id
end
after(:create) do |tag, evaluator|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
.uniq(&:language_code).each do |locale|
next if tag.tag_names.exists?(language_code: locale.language_code,
primary_flg: true)
create(:tag_name,
tag:, language_code: locale.language_code,
script_code: locale.script_code,
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
primary_flg: true, auto_generated: true)
end
end
end
+38
ファイルの表示
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe ExternalTag, type: :model do
it 'preserves names without internal tag sanitisation or TagName records' do
external = nil
expect {
external = described_class.create!(platform: :nico, name: 'raw tag[]')
}.not_to change(TagName, :count)
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version history' do
external = create(:external_tag)
post = create(:post)
PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil)
external.destroy!
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(Tag.exists?(tag.id)).to be(true)
end
end
+32
ファイルの表示
@@ -0,0 +1,32 @@
require 'rails_helper'
RSpec.describe Language, type: :model do
it 'accepts Nipponese as the current supported language' do
expect(described_class.find('ja')).to be_valid
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
end
it 'accepts English when multilingual support is enabled' do
pending '日本語以外の Language 有効化は別課題で実装する'
expect { described_class.create!(code: 'en', name: 'English') }
.to change(described_class, :count).by(1)
end
it 'rejects an unsupported language code' do
expect {
described_class.create!(code: 'xx', name: 'Unsupported')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
['en', 'fr'].each do |code|
it "does not register #{ code } as a currently supported language" do
expect {
described_class.create!(code:, name: 'Unsupported')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
end
end
+129
ファイルの表示
@@ -0,0 +1,129 @@
require 'rails_helper'
RSpec.describe Locale, type: :model do
include_context 'English locale'
def create_english_locale!
prepare_english_reference!
described_class.create!(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '', name: 'English')
end
it 'keeps the active Nipponese locale consistent with its identity tuple' do
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
expect(described_class.nipponese).to have_attributes(
code: 'ja', language_code: 'ja', script_code: 'Jpan',
region_code: 392, variants: '')
end
it 'rejects a duplicate locale identity tuple at the database boundary' do
expect {
described_class.insert_all!([{ code: 'ja-duplicate', language_code: 'ja',
script_code: 'Jpan', region_code: 392,
variants: '', name: '日本語',
created_at: Time.current }])
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'generates a primary name for every existing tag using the name generator' do
pending '日本語以外の Locale 有効化は別課題で実装する'
first = create(:tag, primary_name: 'first_existing_tag')
second = create(:tag, primary_name: 'second_existing_tag')
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
.and_return('name_from_generator')
locale = create_english_locale!
expect(TagName).to have_received(:generate_name)
.with(locale, first, primary_tag_name_for(first, 'ja'))
expect(TagName).to have_received(:generate_name)
.with(locale, second, primary_tag_name_for(second, 'ja'))
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: true)
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(language_code: 'en', primary_flg: true,
tag_id: [first.id, second.id]).count).to eq(2)
end
it 'does not duplicate an existing primary name in the new language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'existing_english', tag:,
language_code: 'en', script_code: 'Latn')
create_english_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true))
.to contain_exactly(existing)
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: false)
end
it 'creates a primary name when the language has only an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'alias_only_tag')
alias_name = create(:tag_name, :alias, name: 'alias_en',
tag:,
language_code: 'en', script_code: 'Latn')
create_english_locale!
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
language_code: 'en', script_code: 'Latn', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'rolls back the locale and all generated names when backfill fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
first = create(:tag, primary_name: 'atomic_first')
second = create(:tag, primary_name: 'atomic_second')
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
.and_raise('backfill failed')
expect { create_english_locale! }.to raise_error('backfill failed')
expect(described_class.exists?(code: 'en')).to be(false)
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
end
it 'accepts a stable locale code outside the historical enum values' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
locale = described_class.create!(
code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
expect(locale.reload).to have_attributes(
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
region_code: 826, variants: 'x-rspec')
end
it 'registers the future British English locale tuple' do
pending '日本語以外の Locale 有効化は別課題で実装する'
expect(locale).to have_attributes(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '')
end
end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) }
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
let(:tag) { create(:tag, primary_name: 'export_item', category: :material) }
let(:material) do
Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user)
+27
ファイルの表示
@@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe NicoTagRelation, type: :model do
it 'does not constrain the ExternalTag association to the nico platform' do
external_tag = create(:external_tag)
tag = create(:tag)
allow(external_tag).to receive(:platform).and_return('registered_external')
allow(external_tag).to receive(:nico?).and_return(false)
expect {
described_class.create!(nico_tag: external_tag, tag:)
}.to change(described_class, :count).by(1)
expect(tag.linked_nico_tags).to contain_exactly(external_tag)
end
it 'rejects an internal Tag through the external association type' do
expect {
described_class.new(nico_tag: create(:tag), tag: create(:tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
it 'rejects an ExternalTag through the internal association type' do
expect {
described_class.new(nico_tag: create(:external_tag), tag: create(:external_tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
end
+25
ファイルの表示
@@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe PostExternalTag, type: :model do
it 'exposes both sides and enforces a unique post and external tag pair' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
expect(post.external_tags).to contain_exactly(external_tag)
expect(external_tag.posts).to contain_exactly(post)
expect { described_class.create!(post:, external_tag:) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'removes associations when the post is deleted while retaining the external tag' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
post.destroy!
expect(described_class.where(post_id: post.id)).to be_empty
expect(external_tag.reload.posts).to be_empty
end
end
+19
ファイルの表示
@@ -6,6 +6,25 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all
end
describe '#snapshot_tags_json' do
it 'keeps internal snapshots and external identifiers distinct, even with the same id' do
post = create(:post)
tag = create(:tag)
external = create(:external_tag, id: tag.id)
create(:post_tag, post:, tag:)
create(:post_tag_section, post:, tag:, begin_ms: 2000, end_ms: nil)
PostExternalTag.create!(post:, external_tag: external)
expect(post.snapshot_tags_json).to eq([
{ 'tag_id' => tag.id,
'version_no' => tag.version_no,
'name' => primary_tag_name_for(tag, 'ja').name,
'category' => tag.category,
'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] },
{ 'external_tag_id' => external.id }])
end
end
describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!(
+79 -4
ファイルの表示
@@ -1,5 +1,73 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do
describe 'uniqueness' do
it 'rejects duplicate post and tag pairs but allows either to be reused' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(duplicate).not_to be_valid
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
end
it 'enforces uniqueness in the database when validation is bypassed' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect { duplicate.save!(validate: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe '#destroy!' do
it 'deletes only the selected pair and its sections and updates the counter' do
post_tag = create(:post_tag)
same_post = create(:post_tag, post: post_tag.post)
same_tag = create(:post_tag, tag: post_tag.tag)
sections = [post_tag, same_post, same_tag].map do |link|
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
.and change(PostTagSection, :count).by(-1)
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
expect(same_post.reload).to be_persisted
expect(same_tag.reload).to be_persisted
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
end
it 'allows a removed tag to be added again without restoring old sections' do
post_tag = create(:post_tag)
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
post_tag.destroy!
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(replacement.reload.sections).to be_empty
expect(replacement.tag.reload.post_count).to eq(1)
end
end
describe '#sections' do
it 'loads the owning post_tag from a section using both keys' do
post_tag = create(:post_tag)
create(:post_tag, post: post_tag.post)
create(:post_tag, tag: post_tag.tag)
section = create(:post_tag_section, post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
expect(section.reload.post_tag).to eq(post_tag)
end
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
@@ -12,18 +80,25 @@ RSpec.describe PostTag, type: :model do
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
post_tag = create(:post_tag)
post = post_tag.post
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
own_section = create(:post_tag_section,
post:,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
create(:post_tag, post:, tag: other_tag)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
expect(post_tag.reload.sections).to contain_exactly(own_section)
end
it 'allows open-ended sections' do
+2 -2
ファイルの表示
@@ -1,8 +1,8 @@
require 'rails_helper'
RSpec.describe PostVersion, type: :model do
let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') }
let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) }
let!(:post_record) do
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post|
+35
ファイルの表示
@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Script, type: :model do
include_context 'English locale'
it 'selects locales by script code, not language code' do
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
end
it 'accepts supported script codes and rejects unsupported ones' do
expect(described_class.find('Jpan')).to be_valid
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['Jpan'])
expect {
described_class.create!(code: 'Zzzz', name: 'Unsupported')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
it 'does not register Latin as a currently supported script' do
expect {
described_class.create!(code: 'Latn', name: 'Latin')
}.to raise_error { |error|
expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid)
}
end
it 'selects Latin locales when multilingual support is enabled' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
expect(described_class.find('Latn').locales).to contain_exactly(locale)
expect(described_class.find('Jpan').locales).not_to include(locale)
end
end
+5 -5
ファイルの表示
@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do
child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, name: 'tag_implication_cycle_parent')
child = create(:tag, primary_name: 'tag_implication_cycle_child')
parent = create(:tag, primary_name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent)
@@ -17,9 +17,9 @@ RSpec.describe TagImplication, type: :model do
end
it 'terminates even when existing data already contains a cycle' do
child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
child = create(:tag, primary_name: 'tag_implication_existing_cycle_child')
parent = create(:tag, primary_name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, primary_name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!(
+87 -21
ファイルの表示
@@ -31,12 +31,48 @@ RSpec.describe TagNameSanitisationRule, type: :model do
describe '.apply!' do
before do
described_class.create!(priority: 10, source_pattern: '_', replacement: '')
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
end
context 'when only another language has the sanitised name' do
include_context 'English locale'
it 'keeps both names when sanitisation produces a cross-language match' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag_name, name: 'foobar')
english = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english.update_columns(name: 'foo_bar')
expect { described_class.apply! }.not_to change(TagName, :count)
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en')
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end
it 'renames without merging tags or deleting either language identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'foobar')
english_name = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
english_name.update_columns(name: 'foo_bar')
described_class.apply!
expect(english_name.reload).to have_attributes(
name: 'foobar', language_code: 'en', tag_id: english.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
end
end
context 'when no conflicting tag_name exists' do
let!(:tag_name) do
TagName.create!(name: 'tmp').tap do |tn|
create(:tag_name, name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
@@ -47,44 +83,72 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
end
context 'when a conflicting canonical tag_name exists' do
let!(:existing) { TagName.create!(name: 'foobar') }
context 'when a conflicting primary tag name exists' do
let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source) do
TagName.create!(name: 'tmp').tap do |tn|
create(:tag_name, name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes the source tag_name' do
described_class.apply!
expect(TagName.exists?(source.id)).to be(false)
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar')
end
end
context 'when the source tag_name has a tag and the existing one has no tag' do
let!(:existing) { TagName.create!(name: 'foobar') }
let!(:source_tag) { create(:tag, name: 'tmp', category: :general) }
let!(:source_tag_name_id) { source_tag.tag_name_id }
let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) }
let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id }
before do
source_tag.tag_name.update_columns(name: 'foo_bar', updated_at: Time.current)
primary_tag_name_for(source_tag, 'ja').update_columns(
name: 'foo_bar', updated_at: Time.current)
end
it 'moves the tag to the existing tag_name' do
described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(TagName.exists?(source_tag_name_id)).to be(false)
expect(existing.reload).to have_attributes(
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing)
expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
let!(:existing_primary) { primary_tag_name_for(existing_tag, 'ja') }
let!(:alias_name) do
create(:tag_name, :alias, name: 'foobar', tag: existing_tag)
end
let!(:source) do
create(:tag_name, name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes only the source and preserves the alias and its owning tag' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(alias_name.reload).to have_attributes(
tag_id: existing_tag.id, language_code: 'ja', primary_flg: false)
expect(TagName.find_by!(tag_id: existing_tag.id,
language_code: 'ja', primary_flg: true))
.to eq(existing_primary)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
end
end
context 'when both source and existing tag_names have tags' do
let!(:existing_tn) { TagName.create!(name: 'foobar') }
let!(:existing_tag) { Tag.create!(tag_name: existing_tn, category: :general) }
let!(:existing_tn) { create(:tag_name, name: 'foobar') }
let!(:existing_tag) { create(:tag, primary_tag_name: existing_tn, category: :general) }
let!(:source_tn) { TagName.create!(name: 'tmp') }
let!(:source_tag) { Tag.create!(tag_name: source_tn, category: :general) }
let!(:source_tn) { create(:tag_name, name: 'tmp') }
let!(:source_tag) { create(:tag, primary_tag_name: source_tn, category: :general) }
let!(:source_tag_name_id) { source_tn.id }
before do
@@ -92,13 +156,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
it 'merges the source tag into the existing tag and deletes the source tag_name' do
expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id)
expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id)
post = create(:post)
PostTag.create!(post:, tag: source_tag)
described_class.apply!
expect(Tag.exists?(source_tag.id)).to be(false)
expect(TagName.exists?(source_tag.tag_name_id)).to be(false)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(post.reload.tags).to contain_exactly(existing_tag)
expect(primary_tag_name_for(existing_tag.reload, 'ja').name).to eq('foobar')
end
end
end
+171
ファイルの表示
@@ -0,0 +1,171 @@
require 'rails_helper'
RSpec.describe TagName, type: :model do
include_context 'English locale'
it 'allows the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
create(:tag_name, name: 'shared_name')
english = build(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn')
expect(english).to be_valid
expect { english.save! }.to change(described_class, :count).by(1)
end
it 'rejects a duplicate name within the same language' do
create(:tag_name, name: 'shared_name')
duplicate = build(:tag_name, name: 'shared_name')
expect(duplicate).to be_invalid
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
end
it 'creates Nipponese factory names without requiring a tag' do
name = create(:tag_name)
expect(name.reload).to have_attributes(
language_code: 'ja', script_code: 'Jpan', primary_flg: true,
auto_generated: false, tag_id: nil)
end
it 'defaults ordinary persisted human names to confirmed provenance' do
name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true)
expect(name.reload.auto_generated).to be(false)
end
it 'persists both sides of the representative tag factory association' do
name = create(:tag_name)
tag = create(:tag, primary_tag_name: name)
expect(name.reload).to have_attributes(
tag_id: tag.id, language_code: 'ja', script_code: 'Jpan', primary_flg: true)
expect(name.tag).to eq(tag)
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
.to eq(name)
expect(tag.reload.tag_name_id).to eq(name.id)
end
it 'creates a complete ordinary tag factory under the active Nipponese language' do
tag = create(:tag, primary_name: 'factory_multilingual')
Locale.where(deprecated_at: nil).distinct.pluck(:language_code).each do |language_code|
expect(TagName.where(tag:, language_code:, primary_flg: true).count).to eq(1)
end
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: false))
end
it 'completes an ordinary tag factory under future active languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: 'future_factory')
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: true))
end
it 'builds a primary name without persisting the tag or its name' do
tag = nil
expect {
tag = build(:tag, primary_name: 'built_primary')
}.to change(Tag, :count).by(0).and change(described_class, :count).by(0)
expect(tag).to be_new_record
expect(tag.tag_names.target).to contain_exactly(have_attributes(
name: 'built_primary', language_code: 'ja', script_code: 'Jpan',
primary_flg: true))
end
it 'persists an alias owned by the same tag as its primary name' do
primary_name = create(:tag_name)
tag = create(:tag, primary_tag_name: primary_name)
alias_name = create(:tag_name, :alias, name: 'valid_alias', tag:)
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
primary_flg: false, language_code: 'ja')
primary = described_class.find_by!(tag_id: alias_name.tag_id,
language_code: alias_name.language_code,
primary_flg: true)
expect(primary).to eq(primary_name)
end
describe '.canonicalise' do
it 'leaves detached aliases unchanged' do
create(:tag_name, :alias, name: 'detached_alias')
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'does not resolve a detached alias through another detached primary' do
create(:tag_name, :alias, name: 'detached_alias')
create(:tag_name, name: 'unrelated_detached_primary')
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'resolves the primary name of the alias language on the same tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語正本')
english_primary = primary_tag_name_for(tag, 'en')
english_primary.update!(name: 'english_primary', auto_generated: false)
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
language_code: 'en', script_code: 'Latn')
representative_id = tag.tag_name_id
primary = described_class.find_by!(tag_id: english_alias.tag_id,
language_code: english_alias.language_code,
primary_flg: true)
expect(primary).to eq(english_primary)
expect(described_class.canonicalise(locale, [english_alias.name]))
.to eq([english_primary.name])
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'resolves only aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.canonicalise(locale, ['shared_alias']))
.to eq(['english_canonical'])
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
.to eq(['nipponese_canonical'])
end
end
it 'rejects the generated-name prefix in human input' do
human_name = build(:tag_name, name: 'Tag_#999', auto_generated: false)
expect(human_name).to be_invalid
end
it 'allows the generated-name prefix for system-created names' do
generated_name = build(:tag_name, name: 'Tag_#1000', auto_generated: true)
expect { generated_name.save! }.to change(described_class, :count).by(1)
end
end
+501 -141
ファイルの表示
@@ -1,129 +1,185 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do
tag = build(:tag, primary_name: name)
expect(tag).to be_invalid
expect(tag.errors[:name]).to be_present
expect {
described_class.normalise_tags!(Locale.nipponese, [name])
}.to raise_error(Tag::NicoTagNormalisationError)
end
end
it 'finds external links by the internal tag id even when ids differ' do
tag = create(:tag)
external = create(:external_tag, id: tag.id + 10_000)
# The migration retains existing links without running model validation.
NicoTagRelation.insert_all!([{ tag_id: tag.id, nico_tag_id: external.id }])
expect(tag.linked_nico_tags).to contain_exactly(external)
end
end
describe '.normalise_tags!' do
include_context 'English locale'
it 'canonicalises aliases in the supplied language only' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
nipponese = create(:tag, primary_name: 'nipponese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
primary_tag_name_for(english, 'ja').update!(
name: 'english_in_nipponese', auto_generated: false)
english_locale = locale
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.normalise_tags!(
english_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([english])
expect(described_class.normalise_tags!(
Locale.nipponese, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
end
it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag')
deprecated_tag = Tag.create!(
tag_name:,
category: :general,
deprecated_at: 1.day.from_now
)
tag_name = create(:tag_name, name: 'normalise deprecated tag')
deprecated_tag = create(:tag,
primary_tag_name: tag_name,
category: :general,
deprecated_at: 1.day.from_now)
expect {
described_class.normalise_tags!(
[deprecated_tag.name],
deny_deprecated: true
)
described_class.normalise_tags!(Locale.nipponese,
[primary_tag_name_for(deprecated_tag, 'ja').name],
deny_deprecated: true)
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
expect(error.tag_names).to eq([deprecated_tag.name])
expect(error.tag_names).to eq([primary_tag_name_for(deprecated_tag, 'ja').name])
}
end
it 'rejects invalid section literals instead of treating them as zero' do
expect {
described_class.normalise_tags!(
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true
)
described_class.normalise_tags!(Locale.nipponese,
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true)
}.to raise_error(Tag::SectionLiteralParseError)
end
it 'parses open-ended section literals' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'parses omitted begin as zero' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-1:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[-1:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end
it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!(
['伊地知ニジカ[0:00-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[0:00-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-1:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-1:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end
it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!(
['伊地知ニジカ[2:00-1:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[2:00-1:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end
it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true
)
result = described_class.normalise_tags!(Locale.nipponese,
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
tag = result.fetch(:tags).find do |candidate|
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
end
describe '.expand_parent_tags' do
it 'expands through multiple deprecated parents to an active ancestor' do
child = create(:tag, name: 'expand_child')
deprecated_parent = create(
:tag,
name: 'expand_deprecated_parent',
deprecated_at: Time.current
)
deprecated_grandparent = create(
:tag,
name: 'expand_deprecated_grandparent',
deprecated_at: Time.current
)
active_ancestor = create(:tag, name: 'expand_active_ancestor')
child = create(:tag, primary_name: 'expand_child')
deprecated_parent = create(:tag,
primary_name: 'expand_deprecated_parent',
deprecated_at: Time.current)
deprecated_grandparent = create(:tag,
primary_name: 'expand_deprecated_grandparent',
deprecated_at: Time.current)
active_ancestor = create(:tag, primary_name: 'expand_active_ancestor')
TagImplication.create!(tag: child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
@@ -140,8 +196,8 @@ RSpec.describe Tag, type: :model do
end
it 'terminates when implications contain a cycle' do
first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second')
first = create(:tag, primary_name: 'expand_cycle_first')
second = create(:tag, primary_name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second)
now = Time.current
TagImplication.insert_all!(
@@ -159,45 +215,282 @@ RSpec.describe Tag, type: :model do
end
end
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
describe '.find_or_create_by_tag_name!' do
context 'with an explicit locale' do
include_context 'English locale'
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
it 'creates the representative name with locale attributes and ownership' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
locale, 'english_name', category: :character)
expect(primary_tag_name_for(tag, 'en')).to have_attributes(
language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tag.id)
expect(tag.category).to eq('character')
end
it 'uses the English primary as the source for a generated Nipponese name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
allow(TagName).to receive(:generate_name).and_return('generated_nipponese')
tag = described_class.find_or_create_by_tag_name!(
locale, 'english_origin', category: :general)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'english_origin', auto_generated: false))
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'generated_nipponese', auto_generated: true))
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
expect(TagName).to have_received(:generate_name)
.with(Locale.nipponese, tag, source_tag_name)
end
it 'gives a new tag one primary name for every active language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'new_multilingual_tag', category: :general)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'creates one primary per language when multiple locales use English' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
described_class.find_or_create_by_tag_name!(
locale, 'before_second_english_locale', category: :general)
Locale.create!(code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
tag = described_class.find_or_create_by_tag_name!(
locale, 'one_english_primary', category: :general)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
it 'rolls back a new tag and requested name if name generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, 'atomic_english_name', category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty
expect(described_class.joins(:tag_names)
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
end
it 'keeps a detached requested name detached if generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'atomic_detached',
language_code: 'en', script_code: 'Latn')
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false)
end
it 'promotes a detached alias into the requested primary name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'detached_english',
language_code: 'en', script_code: 'Latn')
tag = described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(detached)
end
it 'creates separate tag identities for the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)
english = described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :character)
expect(english).not_to eq(nipponese)
expect(primary_tag_name_for(english, 'en')).to have_attributes(
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
expect(described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :general)).to eq(english)
expect(described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
end
it 'preserves the V1 representative when looking up another primary language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語代表名')
representative_id = tag.tag_name_id
english = primary_tag_name_for(tag, 'en')
english.update!(name: 'english_primary', auto_generated: false)
found = described_class.find_or_create_by_tag_name!(
locale, english.name, category: :general)
expect(found).to eq(tag)
expect(tag.reload.tag_name_id).to eq(representative_id)
expect(english.reload.tag_id).to eq(tag.id)
end
it 'normalises names using the supplied locale' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tags = described_class.normalise_tags!(
locale, ['character:normalised_english'],
with_tagme: false, with_no_deerjikist: false)
expect(tags.length).to eq(1)
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tags.first.id)
end
end
it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(Locale.nipponese,
' lookup_new ', category: :character)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(1)
expect(primary_tag_name_for(tag, 'ja').name).to eq('lookup_new')
expect(tag.category).to eq('character')
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
end
it 'promotes a detached Nipponese alias into a human-confirmed primary' do
detached = create(:tag_name, :alias, name: 'detached_nipponese')
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(detached)
end
it 'reuses the owning tag for an alias without changing its category' do
tag = create(:tag, category: :character)
representative_id = tag.tag_name_id
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
primary_flg: false, language_code: 'ja')
found = nil
expect {
found = described_class.find_or_create_by_tag_name!(Locale.nipponese,
alias_name.name, category: :general)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(found).to eq(tag)
expect(found.category).to eq('character')
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'reuses the owning tag through another alias' do
tag = create(:tag)
primary = primary_tag_name_for(tag, 'ja')
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', tag:)
expect([alias_name.reload, other_alias.reload]).to all(have_attributes(
tag_id: tag.id,
primary_flg: false, language_code: 'ja'))
expect {
found = described_class.find_or_create_by_tag_name!(Locale.nipponese,
alias_name.name, category: :general)
expect(found).to eq(tag)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(primary_tag_name_for(tag.reload, 'ja')).to eq(primary)
end
end
describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) }
let!(:source_tag_name) { source_tag.tag_name }
let!(:source_tag_name) { primary_tag_name_for(source_tag, 'ja') }
let!(:post_record) do
Post.create!(url: 'https://example.com/posts/1', title: 'test post')
end
context 'with names in multiple languages' do
include_context 'English locale'
it 'keeps the target primary and moves source names as aliases' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
english_primary = primary_tag_name_for(target_tag, 'en')
source_primary = primary_tag_name_for(source_tag, 'en')
source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
expect([english_primary, source_primary]).to all(have_attributes(
primary_flg: true, auto_generated: true))
described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(english_primary)
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
end
end
context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source tag, moves its post link, and keeps its name as an alias' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -206,39 +499,108 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source link and preserves the existing target link' do
create(:post_tag_section, post: post_record, tag: source_tag,
begin_ms: 1000, end_ms: 2000)
target_section = create(:post_tag_section, post: post_record,
tag: target_tag,
begin_ms: 3000, end_ms: nil)
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
target_links = PostTag.where(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active.count).to eq(1)
expect(active.first.id).to eq(target_post_tag.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_links).to contain_exactly(target_post_tag)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = primary_tag_name_for(source_tag, 'ja').name
source_alias = create(:tag_name, :alias, name: 'merge_alias', tag: source_tag)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: user)
original_version = source_tag.tag_versions.first
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
versions = TagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.first).to eq(original_version)
expect(versions.last.created_by_user).to eq(user)
expect(versions.last.names.fetch('ja')).to eq(
'name' => source_name, 'script_code' => 'Jpan',
'auto_generated' => false,
'aliases' => [{ 'name' => source_alias.name, 'script_code' => 'Jpan' }])
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
target_versions = target_tag.tag_versions.order(:version_no)
expect(target_versions.pluck(:event_type)).to eq(['create', 'update'])
target_aliases = target_versions.last.names.fetch('ja').fetch('aliases').pluck('name')
expect(target_aliases).to contain_exactly('merge_alias', source_name)
end
it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag)
child = create(:tag)
nico_tag = create(:external_tag)
TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
NicoTagRelation.create!(tag: source_tag, nico_tag:)
kept_relation = NicoTagRelation.create!(tag: target_tag, nico_tag:)
TagSimilarity.create!(tag: source_tag, target_tag:, cos: 0.5)
TagSimilarity.create!(tag: target_tag, target_tag: source_tag, cos: 0.5)
kept_similarity = TagSimilarity.create!(tag: target_tag, target_tag: parent,
cos: 0.5)
described_class.merge_tags!(target_tag, [source_tag])
expect(TagImplication.all).to contain_exactly(kept_implication)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
expect(TagSimilarity.all).to contain_exactly(kept_similarity)
expect(TagVersion.where(tag_id: source_tag.id).order(:version_no).last.parent_tag_ids)
.to eq(parent.id.to_s)
end
it 'preserves material history referencing the deleted source tag' do
source_tag.update!(category: :material)
target_tag.update!(category: :material)
material = Material.create!(tag: source_tag, url: 'https://example.com/material')
version = MaterialVersionRecorder.record!(
material:, event_type: :create, created_by_user: nil)
material.update!(tag: target_tag)
described_class.merge_tags!(target_tag, [source_tag])
expect(version.reload).to have_attributes(
tag_id: source_tag.id, tag_name: source_tag_name.name, tag_category: 'material')
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(material.reload.tag).to eq(target_tag)
end
context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(Tag.find(target_tag.id)).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -260,18 +622,17 @@ RSpec.describe Tag, type: :model do
)
end
it 'still merges, but discards the source tag_name instead of aliasing it' do
it 'still merges and keeps the source name as an alias without validating it' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false)
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -288,34 +649,30 @@ RSpec.describe Tag, type: :model do
message: 'init')
end
it 'rolls back the transaction' do
it 'rolls back earlier deletions, links, and history when a later source has a wiki' do
earlier_source = create(:tag)
earlier_name = primary_tag_name_for(earlier_source, 'ja')
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
expect {
described_class.merge_tags!(target_tag, [source_tag])
described_class.merge_tags!(target_tag, [earlier_source, source_tag])
}.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
expect(source_tag_name.reload.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
end
context 'when merging a nico source tag' do
let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag])
discarded_source_tag = Tag.with_discarded.find(source_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id)
expect(discarded_source_tag).to be_discarded
expect(discarded_source_tag_name).to be_discarded
expect(discarded_source_tag_name.canonical_id).to be_nil
expect(Tag.unscoped.exists?(earlier_source.id)).to be(true)
expect(earlier_name.reload).to have_attributes(
tag_id: earlier_source.id, language_code: 'ja', primary_flg: true)
expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
.to be_empty
expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
expect(source_tag.reload.post_count).to eq(1)
expect(source_tag_name.reload).to have_attributes(
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
expect(target_tag.reload.post_count).to eq(0)
end
end
@@ -365,12 +722,15 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') })
.to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1)
end
end
context 'when the source tag has no active post_tags' do
context 'when the source tag has no post_tags' do
let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end
+14 -12
ファイルの表示
@@ -1,21 +1,22 @@
require 'rails_helper'
RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
let!(:tag) { create(:tag, primary_name: 'version_record_tag') }
let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') }
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
created_by_user: nil)
expect {
version.update!(name: 'changed')
@@ -27,13 +28,14 @@ RSpec.describe VersionRecord, type: :model do
tag: tag,
version_no: 1,
event_type: 'create',
name: tag.name,
name: primary_tag_name_for(tag, 'ja').name,
category: tag.category,
aliases: '',
names: { ja: { name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', auto_generated: false, aliases: [] } },
parent_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
created_by_user: nil)
expect {
version.destroy!
@@ -42,10 +44,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
tag: nico_tag,
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current,
created_by_user: nil
@@ -58,10 +60,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
tag: nico_tag,
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: nico_tag.name,
name: "nico:#{ nico_tag.name }",
linked_tags: '',
created_at: Time.current,
created_by_user: nil
+1 -1
ファイルの表示
@@ -30,7 +30,7 @@ RSpec.describe 'Deerjikists API', type: :request do
expect(json['tag']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag1.id)
expect(json['tag']['name']).to eq(tag1.name)
expect(json['tag']['name']).to eq(primary_tag_name_for(tag1, 'ja').name)
end
end
+1 -1
ファイルの表示
@@ -15,7 +15,7 @@ RSpec.describe 'error responses', type: :request do
it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag')
tag = create(:tag, category: :general, primary_name: 'error_response_tag')
sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' }
+7 -8
ファイルの表示
@@ -898,23 +898,22 @@ RSpec.describe 'Gekanator learning API', type: :request do
describe 'GET /gekanator/questions' do
it 'omits questions for deprecated tags' do
active_tag = Tag.create!(name: 'active_question_tag', category: :general)
deprecated_tag = Tag.create!(
name: 'deprecated_question_tag',
category: :general,
deprecated_at: Time.current
)
active_tag = create(:tag, primary_name: 'active_question_tag', category: :general)
deprecated_tag = create(:tag,
primary_name: 'deprecated_question_tag',
category: :general,
deprecated_at: Time.current)
[active_tag, deprecated_tag].each do |question_tag|
GekanatorQuestion.create!(
text: "#{ question_tag.name }?",
text: "#{ primary_tag_name_for(question_tag, 'ja').name }?",
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: {
type: 'tag',
key: "#{ question_tag.category }:#{ question_tag.name }"
key: "#{ question_tag.category }:#{ primary_tag_name_for(question_tag, 'ja').name }"
},
created_by: admin
)
+5 -6
ファイルの表示
@@ -4,12 +4,11 @@ require 'rails_helper'
RSpec.describe 'Gekanator posts API', type: :request do
describe 'GET /gekanator/posts' do
it 'omits deprecated tags and returns the stored similarity cosine' do
active_tag = Tag.create!(name: 'active tag', category: :general)
deprecated_tag = Tag.create!(
name: 'deprecated tag',
category: :general,
deprecated_at: Time.current
)
active_tag = create(:tag, primary_name: 'active tag', category: :general)
deprecated_tag = create(:tag,
primary_name: 'deprecated tag',
category: :general,
deprecated_at: Time.current)
post_record = Post.create!(title: 'source', url: 'https://example.com/source')
target_post = Post.create!(title: 'target', url: 'https://example.com/target')

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示