コミットを比較

..
102 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 817cd6ae73 #399 2026-07-18 23:06:12 +09:00
みてるぞ dc7353304f #399 2026-07-18 22:53:36 +09:00
みてるぞ 5ff3fc9441 #399 2026-07-18 22:32:34 +09:00
みてるぞ a847d93d2f #399 2026-07-18 22:18:20 +09:00
みてるぞ 2f87669699 #399 2026-07-18 21:59:22 +09:00
みてるぞ 1c906d7432 #399 2026-07-18 21:19:53 +09:00
みてるぞ f662dc9dc0 #399 2026-07-18 20:22:53 +09:00
みてるぞ 206c6bc0a0 #399 2026-07-18 20:10:15 +09:00
みてるぞ 8f66ee8059 #399 2026-07-18 20:03:26 +09:00
みてるぞ 83e3db3314 #399 2026-07-18 20:00:40 +09:00
みてるぞ eae6c30064 #399 2026-07-18 19:26:27 +09:00
みてるぞ 240e078f0b #399 2026-07-18 18:33:11 +09:00
みてるぞ 74b1ada0dd #399 2026-07-18 17:44:56 +09:00
みてるぞ 23d8adf65d #399 2026-07-18 17:35:28 +09:00
みてるぞ e0debed94e #399 2026-07-18 17:31:57 +09:00
みてるぞ 583ce22a7e #399 2026-07-18 17:07:56 +09:00
みてるぞ 7c808a6f76 #399 2026-07-18 15:15:10 +09:00
みてるぞ a5ae7c6f2d #399 2026-07-18 14:55:11 +09:00
みてるぞ 03dc4d0661 #399 2026-07-18 13:32:22 +09:00
みてるぞ 06b9c1cb50 #399 2026-07-18 13:07:49 +09:00
みてるぞ 688b4af575 #399 2026-07-18 12:53:50 +09:00
みてるぞ 040cc3f25d #399 2026-07-18 12:15:32 +09:00
みてるぞ b3e67d8cca #399 2026-07-18 11:55:09 +09:00
みてるぞ b00a62a0ac #399 2026-07-18 09:11:11 +09:00
みてるぞ e6c3c635b8 #399 2026-07-18 02:29:55 +09:00
みてるぞ 9552081133 #399 2026-07-18 02:08:20 +09:00
みてるぞ 2d2a9b4bd6 #399 2026-07-18 01:37:11 +09:00
みてるぞ 09ac2576bb #399 2026-07-18 01:28:22 +09:00
みてるぞ 9eca670934 #399 2026-07-18 01:12:55 +09:00
みてるぞ c31d84115d #399 2026-07-18 00:31:02 +09:00
みてるぞ ff970f8171 #399 2026-07-18 00:01:12 +09:00
みてるぞ 0ae41b6266 #399 2026-07-17 23:14:37 +09:00
みてるぞ eb4bf5e35c #399 2026-07-17 22:21:18 +09:00
みてるぞ e6b7e33b83 #399 2026-07-17 21:53:24 +09:00
みてるぞ 3820d3d4d5 #399 2026-07-17 21:06:26 +09:00
みてるぞ dd2d199d04 #399 2026-07-16 23:39:13 +09:00
みてるぞ 0a8ffc38b8 #399 2026-07-16 23:23:32 +09:00
みてるぞ 3f75994bd4 #399 2026-07-16 23:12:14 +09:00
みてるぞ f91b78bd47 #399 2026-07-16 22:31:32 +09:00
みてるぞ 7f8cce39bc #399 2026-07-16 22:05:40 +09:00
みてるぞ 5c5a9fa1b0 #399 2026-07-16 22:00:16 +09:00
みてるぞ 66c738cbff #399 2026-07-16 21:45:02 +09:00
みてるぞ cde0a2deae #399 2026-07-16 20:54:42 +09:00
みてるぞ 43a3772976 #399 2026-07-16 20:35:07 +09:00
みてるぞ 90d8d3ff08 #399 2026-07-16 20:05:09 +09:00
みてるぞ d1de631eed #399 2026-07-16 19:35:21 +09:00
みてるぞ 6f3ccb0ffa #399 2026-07-16 19:18:35 +09:00
みてるぞ 95c3f08a44 #399 2026-07-16 18:52:07 +09:00
みてるぞ ef95b20a7e #399 2026-07-16 18:33:45 +09:00
みてるぞ f76fbe6711 #399 2026-07-16 18:16:20 +09:00
みてるぞ 0224c4d2f4 #399 2026-07-16 12:39:35 +09:00
みてるぞ ead825e3ee #399 2026-07-16 07:56:45 +09:00
みてるぞ 3d88fdecb1 #399 2026-07-16 01:19:36 +09:00
みてるぞ 2e1b4449ba #399 2026-07-16 00:44:11 +09:00
みてるぞ 4eb483c049 #399 2026-07-16 00:24:24 +09:00
みてるぞ e197f27e9a #399 2026-07-15 23:59:01 +09:00
みてるぞ 34f81325d2 #399 2026-07-15 23:42:05 +09:00
みてるぞ 9af4670c6c #399 2026-07-15 23:29:19 +09:00
みてるぞ 5183b0b354 #399 2026-07-15 23:06:28 +09:00
みてるぞ d0914fce3b #399 2026-07-15 22:51:01 +09:00
みてるぞ 0ac7332458 #399 2026-07-15 22:41:06 +09:00
みてるぞ 07ce19e32d #399 2026-07-15 22:26:58 +09:00
みてるぞ 5f1d619139 #399 2026-07-15 22:04:47 +09:00
みてるぞ 3cd22855b3 #399 2026-07-15 21:42:00 +09:00
みてるぞ 55c69866a7 #399 2026-07-15 21:37:03 +09:00
みてるぞ 04d2f0f5a9 #399 2026-07-15 20:59:00 +09:00
みてるぞ 025f49cbcb #399 2026-07-15 20:51:24 +09:00
みてるぞ d2f1b1ed48 #399 2026-07-15 20:25:51 +09:00
みてるぞ 6e5aa1e30f #399 2026-07-15 19:57:09 +09:00
みてるぞ 21d6c1408a #399 2026-07-15 19:43:39 +09:00
みてるぞ eebbd5d069 #399 2026-07-15 19:33:18 +09:00
みてるぞ 58828597d7 #399 2026-07-15 08:03:25 +09:00
みてるぞ b0c24f319a #399 2026-07-14 20:03:11 +09:00
みてるぞ f9463f383f #399 2026-07-14 19:37:04 +09:00
みてるぞ 6d037192c4 #399 2026-07-14 19:12:23 +09:00
みてるぞ d035da99ad #399 2026-07-14 18:39:24 +09:00
みてるぞ bc660676ef Merge remote-tracking branch 'origin/main' into feature/399 2026-07-14 12:40:32 +09:00
みてるぞ 97b132e5ab #399 2026-07-13 01:31:45 +09:00
みてるぞ be68841bd3 #399 2026-07-13 01:17:11 +09:00
みてるぞ ac82adc6b3 #399 2026-07-13 00:58:02 +09:00
みてるぞ aa96ec95d1 #399 2026-07-12 14:03:05 +09:00
みてるぞ df17f20907 #399 2026-07-12 13:14:36 +09:00
みてるぞ 769966648b #399 2026-07-12 12:43:31 +09:00
みてるぞ 7bcb76516c #399 2026-07-12 12:23:53 +09:00
みてるぞ 8970edc59f #399 2026-07-12 11:17:23 +09:00
みてるぞ 9b1ea56e36 #399 2026-07-12 10:27:13 +09:00
みてるぞ 155edfe018 #399 2026-07-12 02:22:36 +09:00
みてるぞ 23f1ffb04b #399 2026-07-12 02:08:32 +09:00
みてるぞ ccfe65a6a4 #399 2026-07-12 02:01:25 +09:00
みてるぞ 06d6e512e4 #399 2026-07-12 01:56:24 +09:00
みてるぞ 5bd097bcfe #399 2026-07-12 01:47:34 +09:00
みてるぞ f636d2a177 #399 2026-07-12 01:39:31 +09:00
みてるぞ 10dc776313 #399 2026-07-12 01:17:45 +09:00
みてるぞ a9e16735f8 #399 2026-07-12 01:11:07 +09:00
みてるぞ 440a6c9961 #399 2026-07-12 00:54:21 +09:00
みてるぞ 4535a9d260 #399 2026-07-12 00:46:53 +09:00
みてるぞ 440d3d38be #399 2026-07-11 23:21:59 +09:00
みてるぞ cb33d9ca25 #399 2026-07-11 23:08:03 +09:00
みてるぞ 19bf24432a #399 2026-07-11 23:07:51 +09:00
みてるぞ ac41385962 Merge remote-tracking branch 'origin/main' into feature/399 2026-07-11 22:36:03 +09:00
みてるぞ 08bf92ff79 #399 2026-07-11 21:46:10 +09:00
みてるぞ c51d7b98ba #399 2026-07-11 21:11:36 +09:00
137個のファイルの変更、1814行の追加、5993行の削除
+2 -26
ファイルの表示
@@ -574,32 +574,8 @@ and layout reuse, follow `frontend/AGENTS.md`.
wording and placement before implementing it. wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording. - Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for - Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working within the permitted test work. When the user asks for tests, keep working and rerun them until
test-file scope and rerun them until they pass or the remaining failure is they pass or the remaining failure is clearly blocked.
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
contracts, validation, routes, authentication, permissions, UI, copy,
dependencies, limits, thresholds, defaults, migrations, schema, or
environment settings to satisfy tests.
- Do not make production code match failing tests, mock assumptions, fixtures,
snapshots, old expectations, or stale setup. This includes changing
production constants merely because a test expects a different value.
- If test work reveals a production bug, spec mismatch, or missing behaviour,
stop without modifying production code and report: the failing test or
discovered issue, the related production file, the actual behaviour, the
expected behaviour, and why a production change appears necessary.
- Modify production code for test failures only when the user explicitly asks
for that production change. Do not expand a test task into a production task
on your own authority.
- If the user explicitly asks for both production implementation and test
updates, implement production code to the confirmed specification first,
then add or update tests to verify that specification. Never roll production
behaviour back to satisfy old tests.
- If it is unclear whether the test or the production implementation is stale,
or a test cannot be corrected without changing production code, ask the user
instead of guessing.
## Backend rules ## Backend rules
-9
ファイルの表示
@@ -85,13 +85,4 @@ class ApplicationController < ActionController::API
base_errors: }, base_errors: },
status: status:
end end
def normalise_json value
return nil if value.nil?
return JSON.parse(value) if value.is_a?(String)
value
end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end end
+3 -6
ファイルの表示
@@ -1,7 +1,5 @@
class DeerjikistsController < ApplicationController class DeerjikistsController < ApplicationController
def show def show
locale = resolve_locale!
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank? return render_bad_request('platform は必須です.') if platform.blank?
@@ -9,10 +7,10 @@ class DeerjikistsController < ApplicationController
deerjikist = Deerjikist deerjikist = Deerjikist
.joins(:tag) .joins(:tag)
.includes(tag: :tag_names) .includes(tag: :tag_name)
.find_by(platform:, code:) .find_by(platform:, code:)
if deerjikist if deerjikist
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code) render json: DeerjikistRepr.base(deerjikist)
else else
head :not_found head :not_found
end end
@@ -22,7 +20,6 @@ class DeerjikistsController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i tag_id = params[:tag_id].to_i
@@ -35,7 +32,7 @@ class DeerjikistsController < ApplicationController
d.save! d.save!
end end
render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code) render json: DeerjikistRepr.base(deerjikist)
end end
def destroy def destroy
+2 -2
ファイルの表示
@@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController
def index def index
posts = posts =
Post Post
.preload(:post_similarities, tags: :tag_names) .preload(:post_similarities, tags: :tag_name)
.with_attached_thumbnail .with_attached_thumbnail
.order(Arel.sql( .order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
@@ -56,7 +56,7 @@ class GekanatorPostsController < ApplicationController
def tag_json tag def tag_json tag
{ {
id: tag.id, id: tag.id,
name: tag.name('ja'), name: tag.name,
category: tag.category category: tag.category
} }
end end
+2 -2
ファイルの表示
@@ -126,9 +126,9 @@ class GekanatorQuestionsController < ApplicationController
end end
Tag Tag
.joins(:tag_names) .joins(:tag_name)
.where(category: categories.uniq) .where(category: categories.uniq)
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names.uniq }) .where(tag_names: { name: names.uniq })
.where.not(deprecated_at: nil) .where.not(deprecated_at: nil)
.pluck('tags.category', 'tag_names.name') .pluck('tags.category', 'tag_names.name')
.each_with_object({ }) do |(category, name), h| .each_with_object({ }) do |(category, name), h|
+20 -41
ファイルの表示
@@ -7,8 +7,6 @@ class MaterialsController < ApplicationController
rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file
def index def index
locale = resolve_locale!
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -22,7 +20,7 @@ class MaterialsController < ApplicationController
q = Material.includes(:material_export_items, q = Material.includes(:material_export_items,
thumbnail_attachment: :blob, thumbnail_attachment: :blob,
file_attachment: :blob, file_attachment: :blob,
tag: :tag_names) tag: :tag_name)
q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged'
q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged'
q = apply_material_tag_filter(q, filters, tag_graph) q = apply_material_tag_filter(q, filters, tag_graph)
@@ -44,10 +42,7 @@ class MaterialsController < ApplicationController
.offset(offset) .offset(offset)
.to_a .to_a
response = { materials: MaterialRepr.list_many( response = { materials: MaterialRepr.list_many(materials, host: request.base_url),
materials,
host: request.base_url,
language_code: locale.language_code),
count: } count: }
if filters[:tag_id] if filters[:tag_id]
response[:tag_scope] = material_index_tag_scope(filters, tag_graph) response[:tag_scope] = material_index_tag_scope(filters, tag_graph)
@@ -60,8 +55,6 @@ class MaterialsController < ApplicationController
end end
def show def show
locale = resolve_locale!
material = material =
Material Material
.includes(:tag, :material_export_items) .includes(:tag, :material_export_items)
@@ -70,17 +63,15 @@ class MaterialsController < ApplicationController
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless material return head :not_found unless material
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url, render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
language_code: locale.language_code).merge(wiki_page_body:)
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
file_sha256 = MaterialFileSha256.from_upload(file) file_sha256 = MaterialFileSha256.from_upload(file)
@@ -98,14 +89,13 @@ class MaterialsController < ApplicationController
begin begin
Material.transaction do Material.transaction do
tag = resolve_material_tag!(locale, tag_name_raw) tag = resolve_material_tag!(tag_name_raw)
material = Material.new(tag:, url:, material = Material.new(tag:, url:,
created_by_user: current_user, created_by_user: current_user,
updated_by_user: current_user) updated_by_user: current_user)
material.file.attach(uploaded_blob) if uploaded_blob material.file.attach(uploaded_blob) if uploaded_blob
material.save! 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) upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :create, MaterialVersionRecorder.record!(material:, event_type: :create,
created_by_user: current_user) created_by_user: current_user)
@@ -117,8 +107,7 @@ class MaterialsController < ApplicationController
if material if material
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material)) 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), status: :created
language_code: locale.language_code), status: :created
else else
render_validation_error material render_validation_error material
end end
@@ -143,14 +132,12 @@ class MaterialsController < ApplicationController
block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
return render_material_import_block(block) if block return render_material_import_block(block) if block
locale = resolve_locale!
uploaded_blob = build_uploaded_material_blob!(file, file_sha256) uploaded_blob = build_uploaded_material_blob!(file, file_sha256)
begin begin
Material.transaction do Material.transaction do
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
tag = resolve_material_tag!(locale, tag_name_raw) tag = resolve_material_tag!(tag_name_raw)
material.assign_attributes(tag:, url:, updated_by_user: current_user) material.assign_attributes(tag:, url:, updated_by_user: current_user)
if uploaded_blob if uploaded_blob
material.file.attach(uploaded_blob) material.file.attach(uploaded_blob)
@@ -158,8 +145,7 @@ class MaterialsController < ApplicationController
material.file.detach material.file.detach
end end
material.save! 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) upsert_export_paths!(material)
MaterialVersionRecorder.record!(material:, event_type: :update, MaterialVersionRecorder.record!(material:, event_type: :update,
created_by_user: current_user) created_by_user: current_user)
@@ -171,8 +157,7 @@ class MaterialsController < ApplicationController
log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material)) 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 end
def destroy def destroy
@@ -251,8 +236,10 @@ class MaterialsController < ApplicationController
nil nil
end end
def resolve_material_tag! locale, tag_name_raw def resolve_material_tag! tag_name_raw
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end end
def material_index_needs_tag_name? filters def material_index_needs_tag_name? filters
@@ -266,12 +253,7 @@ class MaterialsController < ApplicationController
end end
def material_index_join_tag_name q def material_index_join_tag_name q
q.left_joins(:tag).joins(<<~SQL.squish) q.left_joins(tag: :tag_name)
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 end
def material_index_join_file_blob q def material_index_join_file_blob q
@@ -287,11 +269,9 @@ class MaterialsController < ApplicationController
def apply_material_query q, term def apply_material_query q, term
like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%" like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%"
q.where(<<~SQL.squish, q: like) q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \
tag_names.name LIKE :q 'material_file_blobs.filename LIKE :q',
OR materials.url LIKE :q q: like)
OR material_file_blobs.filename LIKE :q
SQL
end end
def apply_material_media_kind q, media_kind def apply_material_media_kind q, media_kind
@@ -343,9 +323,8 @@ class MaterialsController < ApplicationController
end end
tags_by_id = tags_by_id =
Tag Tag
.joins(:tag_names) .joins(:tag_name)
.where(id: scope_tag_ids, .where(id: scope_tag_ids)
tag_names: { language_code: 'ja', primary_flg: true })
.pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.each_with_object({ }) do |(id, name, category, deprecated_at), hash| .each_with_object({ }) do |(id, name, category, deprecated_at), hash|
hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? } hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? }
+30 -67
ファイルの表示
@@ -1,7 +1,5 @@
class NicoTagsController < ApplicationController class NicoTagsController < ApplicationController
def index def index
locale = resolve_locale!
name = params[:name].presence name = params[:name].presence
linked_tag = params[:linked_tag].presence linked_tag = params[:linked_tag].presence
link_status = params[:link_status].presence link_status = params[:link_status].presence
@@ -17,36 +15,30 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1 limit = 1 if limit < 1
post_tag_max_sql = post_tag_max_sql =
PostExternalTag PostTag
.select('external_tag_id, MAX(created_at) AS max_created_at') .select('tag_id, MAX(created_at) AS max_created_at')
.group('external_tag_id') .group('tag_id')
.to_sql .to_sql
q = q = Tag.nico_tags
ExternalTag .joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id') 'ON post_tag_max.tag_id = tags.id')
.includes(linked_tags: { tag_names: :wiki_page }) .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
if name q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(('external_tags.name LIKE ? ' +
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
"%#{ name }%", "%#{ name }")
end
if linked_tag if linked_tag
linked_tag_ids = linked_tag_ids =
Tag Tag
.joins(:tag_names) .joins(:tag_name)
.where(tag_names: { language_code: 'ja', primary_flg: true }) .where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.where('tag_names.name LIKE ?', "%#{ linked_tag }%") .pluck(:id)
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids) q = q.where(id: linked_nico_tag_ids)
end end
if link_status.in?(['linked', 'unlinked']) if link_status.in?(['linked', 'unlinked'])
exists_sql = exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \ 'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)' 'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end end
@@ -54,68 +46,54 @@ class NicoTagsController < ApplicationController
sort_sql = sort_sql =
case order[0] case order[0]
when 'name' when 'name'
'external_tags.name' 'tag_names.name'
when 'updated_at' when 'updated_at'
'post_tag_max.max_created_at' 'post_tag_max.max_created_at'
else else
"external_tags.#{ order[0] }" "tags.#{ order[0] }"
end end
tags = q.reselect('external_tags.*', tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at')) Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }")) .order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit) .limit(limit)
.offset((page - 1) * limit) .offset((page - 1) * limit)
.to_a .to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
external_tag_json(tag).merge( TagRepr.base(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at, recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
TagRepr.base(lt, language_code: locale.language_code)
})
}, count: } }, count: }
end end
def show
tag = ExternalTag.find(params[:id])
render json: external_tag_json(tag)
end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
id = params[:id].to_i id = params[:id].to_i
tag = ExternalTag.find(id) tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
linked_tag_names = params[:tags].to_s.split linked_tag_names = params[:tags].to_s.split
linked_tags = nil linked_tags = nil
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(locale, linked_tag_names, linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_tagme: false, with_no_deerjikist: false)
with_no_deerjikist: false) if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!( TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
linked_tags,
created_by_user: current_user,
language_code: locale.language_code)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
tag.save! tag.save!
NicoTagVersionRecorder.record!(external_tag: tag, NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
event_type: :update,
created_by_user: current_user)
end end
render json: tag.linked_tags.map { |t| render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
TagRepr.base(t, language_code: locale.language_code)
}, status: :ok
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] } render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -124,21 +102,6 @@ class NicoTagsController < ApplicationController
private 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 def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag) if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message| render_validation_error fields: { tags: record.errors.full_messages.map { |message|
+67 -84
ファイルの表示
@@ -1,10 +1,7 @@
class PostVersionsController < ApplicationController class PostVersionsController < ApplicationController
def index def index
post_id = params[:post].presence post_id = params[:post].presence
tag_id = params[:tag].presence&.to_i tag_id = params[:tag].presence
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 page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -13,6 +10,12 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tag_name =
if tag_id
TagName.joins(:tag).find_by(tag: { id: tag_id })
end
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
q = PostVersion.joins(<<~SQL.squish) q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN LEFT JOIN
post_versions prev post_versions prev
@@ -20,23 +23,17 @@ class PostVersionsController < ApplicationController
prev.post_id = post_versions.post_id prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1 AND prev.version_no = post_versions.version_no - 1
SQL SQL
.select('post_versions.*', .select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.title AS prev_title', 'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms', 'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from', 'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) if tag_name
q = q.where('JSON_CONTAINS(post_versions.tags_json,' + escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " + q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
'OR JSON_CONTAINS(prev.tags_json,' + "OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))") kw: "% #{ escaped } %")
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 end
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
@@ -51,91 +48,77 @@ class PostVersionsController < ApplicationController
private private
def serialise_versions rows def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h 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| rows.map do |row|
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names) cur_tags = split_tags(row.tags)
prev_tags = snapshot_tag_literals( prev_tags = split_tags(row.attributes['prev_tags'])
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
{ post_id: row.post_id, {
post_id: row.post_id,
version_no: row.version_no, version_no: row.version_no,
event_type: row.event_type, event_type: row.event_type,
title: { current: row.title, prev: row.attributes['prev_title'] }, title: {
url: { current: row.url, prev: row.attributes['prev_url'] }, current: row.title,
thumbnail: { current: nil, prev: nil }, prev: row.attributes['prev_title']
thumbnail_base: { current: row.thumbnail_base, },
prev: row.attributes['prev_thumbnail_base'] }, url: {
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] }, current: row.url,
prev: row.attributes['prev_url']
},
thumbnail: {
current: nil,
prev: nil
},
thumbnail_base: {
current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base']
},
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 }, prev: row.attributes['prev_original_created_from']&.iso8601
},
original_created_before: { original_created_before: {
current: row.original_created_before&.iso8601, current: row.original_created_before&.iso8601,
prev: row.attributes['prev_original_created_before']&.iso8601 }, prev: row.attributes['prev_original_created_before']&.iso8601
},
created_at: row.created_at.iso8601, created_at: row.created_at.iso8601,
created_by_user: created_by_user:
if row.created_by_user_id if row.created_by_user_id
{ id: row.created_by_user_id, {
name: users_by_id[row.created_by_user_id] } id: row.created_by_user_id,
end } name: users_by_id[row.created_by_user_id]
}
end
}
end end
end end
def external_tag_names_for snapshots def build_version_tags(cur_tags, prev_tags)
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| (cur_tags | prev_tags).map do |name|
type = type =
if cur_tags.include?(name) && prev_tags.include?(name) if cur_tags.include?(name) && prev_tags.include?(name)
'context' 'context'
elsif cur_tags.include?(name) elsif cur_tags.include?(name)
'added' 'added'
else else
'removed' 'removed'
end end
{ name:, type: } {
name:,
type:
}
end end
end end
def split_tags(tags)
tags.to_s.split(/\s+/).reject(&:blank?)
end
end end
+115 -106
ファイルの表示
@@ -7,8 +7,6 @@ class PostsController < ApplicationController
end end
def index def index
locale = resolve_locale!
url = params[:url].presence url = params[:url].presence
title = params[:title].presence title = params[:title].presence
original_created_from = params[:original_created_from].presence original_created_from = params[:original_created_from].presence
@@ -38,8 +36,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
pt_max_sql = pt_max_sql =
PostVersion PostTag
.select('post_id, MAX(created_at) AS max_updated_at') .select('post_id, MAX(updated_at) AS max_updated_at')
.group('post_id') .group('post_id')
.to_sql .to_sql
@@ -48,12 +46,13 @@ class PostsController < ApplicationController
'COALESCE(pt_max.max_updated_at, posts.updated_at))' 'COALESCE(pt_max.max_updated_at, posts.updated_at))'
q = q =
filtered_posts(locale) filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .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")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:external_tags, :uploaded_user, :parents, :children, .preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, active_post_tags: [:sections,
{ tag_names: :wiki_page }] }]) { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -92,8 +91,7 @@ class PostsController < ApplicationController
q = q.except(:select, :order) q = q.except(:select, :order)
render json: { posts: posts.map { |post| render json: { posts: posts.map { |post|
PostRepr.base(post, language_code: locale.language_code) PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json|
.merge(updated_at: post.updated_at_all).tap do |json|
json['thumbnail'] = json['thumbnail'] =
if post.thumbnail.attached? if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false) rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -105,19 +103,16 @@ class PostsController < ApplicationController
end end
def random def random
locale = resolve_locale! post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections,
post = { tag: [:deerjikists, :materials,
filtered_posts(locale) { tag_name: :wiki_page }] }])
.preload(:uploaded_user, :parents, :children, .with_attached_thumbnail
post_tags: [:sections, { tag: [:deerjikists, :materials, .order('RAND()')
{ tag_names: :wiki_page }] }]) .first
.with_attached_thumbnail
.order('RAND()')
.first
return head :not_found unless post return head :not_found unless post
render json: PostRepr.base(post, current_user, language_code: locale.language_code) render json: PostRepr.base(post, current_user)
end end
def metadata def metadata
@@ -192,13 +187,12 @@ class PostsController < ApplicationController
end end
def show def show
locale = resolve_locale!
post = post =
Post Post
.includes(:uploaded_user, :parents, :children, .includes(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials, active_post_tags: [:sections,
{ tag_names: :wiki_page }] }]) { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
@@ -212,21 +206,18 @@ class PostsController < ApplicationController
parent_posts:, parent_posts:,
child_posts:, child_posts:,
sibling_posts:, sibling_posts:,
related:, related:)
language_code: locale.language_code) .merge(tags: build_tag_tree_for(post))
.merge(tags: build_tag_tree_for(post, locale.language_code))
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
preflight = PostCreatePreflight.new( preflight = PostCreatePreflight.new(
attributes: post_create_attributes, attributes: post_create_attributes,
thumbnail: params[:thumbnail], thumbnail: params[:thumbnail],
host: request.base_url).run(locale) host: request.base_url).run
return render json: dry_run_json(preflight) if bool?(:dry) return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present? if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url]) post = Post.new(url: preflight[:url])
@@ -252,10 +243,10 @@ class PostsController < ApplicationController
:post_tag_specs, :post_tag_specs,
:tag_sections, :tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge( :normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!(locale) thumbnail: params[:thumbnail])).create!
post.reload post.reload
render json: PostRepr.base(post, language_code: locale.language_code), status: :created render json: PostRepr.base(post), status: :created
rescue PostCreatePreflight::ValidationFailed => e rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
@@ -281,18 +272,13 @@ class PostsController < ApplicationController
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form] 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 return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
locale = resolve_locale!
posts = parse_bulk_posts_manifest posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length) thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new( result = PostBulkCreator.new(
actor: current_user, actor: current_user,
posts:, posts:,
thumbnails:, thumbnails:,
host: request.base_url).run(locale) host: request.base_url).run
render json: result render json: result
rescue JSON::ParserError rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.' render_bad_request 'posts manifest の JSON が不正です.'
@@ -325,7 +311,6 @@ class PostsController < ApplicationController
base_version_no = parse_base_version_no base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(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 title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
@@ -345,10 +330,9 @@ class PostsController < ApplicationController
base_version = post.post_versions.find_by!(version_no: base_version_no) base_version = post.post_versions.find_by!(version_no: base_version_no)
base_snapshot = post_snapshot_from_version(base_version) base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post, locale) current_snapshot = post_snapshot_from_record(post)
end end
incoming_snapshot = post_incoming_snapshot(locale:, incoming_snapshot = post_incoming_snapshot(title:,
title:,
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
@@ -377,14 +361,14 @@ class PostsController < ApplicationController
end end
end end
apply_post_snapshot!(locale, post, snapshot_to_apply) apply_post_snapshot!(post, snapshot_to_apply)
end end
return render json: conflict_json, status: :conflict if conflict_json return render json: conflict_json, status: :conflict if conflict_json
post.reload post.reload
json = PostRepr.base(post, current_user, language_code: locale.language_code) json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post, locale.language_code) json['tags'] = build_tag_tree_for(post)
render json:, status: :ok render json:, status: :ok
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
@@ -400,21 +384,65 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record render_post_form_record_invalid e.record
end 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 private
def filtered_posts locale def filtered_posts
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
match_type = params[:match] match_type = params[:match]
if tag_names.present? if tag_names.present?
filter_posts_by_tags(locale, tag_names, match_type) filter_posts_by_tags(tag_names, match_type)
else else
Post.all Post.all
end end
end end
def filter_posts_by_tags locale, tag_names, match_type def filter_posts_by_tags tag_names, match_type
literals = tag_names.map do |raw_name| literals = tag_names.map do |raw_name|
{ name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first, { name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first,
negative: raw_name.downcase.start_with?('not:') } negative: raw_name.downcase.start_with?('not:') }
end end
@@ -422,11 +450,11 @@ class PostsController < ApplicationController
if match_type == 'any' if match_type == 'any'
literals.reduce(Post.none) do |posts, literal| literals.reduce(Post.none) do |posts, literal|
posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative])) posts.or(tag_literal_relation(literal[:name], negative: literal[:negative]))
end end
else else
literals.reduce(Post.all) do |posts, literal| literals.reduce(Post.all) do |posts, literal|
ids = tagged_post_ids_for(locale, literal[:name]) ids = tagged_post_ids_for(literal[:name])
if literal[:negative] if literal[:negative]
posts.where.not(id: ids) posts.where.not(id: ids)
else else
@@ -436,8 +464,8 @@ class PostsController < ApplicationController
end end
end end
def tag_literal_relation locale, name, negative: def tag_literal_relation name, negative:
ids = tagged_post_ids_for(locale, name) ids = tagged_post_ids_for(name)
if negative if negative
Post.where.not(id: ids) Post.where.not(id: ids)
else else
@@ -445,20 +473,8 @@ class PostsController < ApplicationController
end end
end end
def tagged_post_ids_for locale, name def tagged_post_ids_for(name) =
posts_by_internal_tags = Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
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 def sync_post_tags! post, desired_tags, sections
desired_tags.each do |t| desired_tags.each do |t|
@@ -486,13 +502,13 @@ class PostsController < ApplicationController
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.destroy! pt.discard_by!(current_user)
end end
end end
def build_tag_tree_for post, language_code = 'ja' def build_tag_tree_for post
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? } post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag) tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
@@ -521,7 +537,7 @@ class PostsController < ApplicationController
sections = sections_by_tag_id.fetch(tag_id, []) sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.inline(tag, language_code:).merge(children: [], sections:) return TagRepr.inline(tag).merge(children: [], sections:)
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -533,13 +549,10 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) } children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag, language_code:).merge(children:, sections:) memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
end end
internal_tags = root_ids.filter_map { |id| build_node.call(id, []) } 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 end
def sibling_posts_by_parent parent_post_ids def sibling_posts_by_parent parent_post_ids
@@ -686,49 +699,43 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_version version def editable_tag_names_from_version version
version.tags_json version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
.select { _1.key?('tag_id') }
.map { Post.tag_snapshot_literal(_1) }
.compact
.sort
end end
def post_snapshot_from_record post, locale def post_snapshot_from_record post
{ title: post.title, { title: post.title,
video_ms: post.video_ms, video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from), original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before), original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post, locale.language_code), tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) } parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end end
def editable_tag_names_from_post post, language_code def editable_tag_names_from_post post
post post
.post_tags .post_tags
.joins(tag: :tag_names) .kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil)) .merge(Tag.where(deprecated_at: nil))
.where(tag_names: { language_code:, primary_flg: true }) .includes(:sections, tag: :tag_name)
.includes(:sections, tag: :tag_names)
.order('tag_names.name') .order('tag_names.name')
.map do |post_tag| .map do |post_tag|
name = post_tag.tag.name(language_code) name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms) sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty? next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }" "#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end.compact.sort end
end end
def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:, def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids: tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before) validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
Tag.normalise_tags!(locale, tag_names, with_sections: true) =>
with_tagme: false, { tags:, sections: }
with_no_deerjikist: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:) video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -739,7 +746,7 @@ class PostsController < ApplicationController
original_created_from: snapshot_time(original_created_from), original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before), original_created_before: snapshot_time(original_created_before),
tag_names: tags.uniq(&:id).map { |tag| tag_names: tags.uniq(&:id).map { |tag|
"#{ tag.name(locale.language_code) }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }" "#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort, }.sort,
parent_post_ids: parent_post_ids.sort } parent_post_ids: parent_post_ids.sort }
end end
@@ -863,7 +870,7 @@ class PostsController < ApplicationController
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end end
def apply_post_snapshot! locale, post, snapshot def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title], post.update!(title: snapshot[:title],
@@ -871,13 +878,15 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(locale, snapshot[:tag_names], Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
with_tagme: false, deny_deprecated: true,
deny_deprecated: true, with_sections: true) =>
with_sections: true) => { tags:, sections: } { tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user, TagVersioning.record_tag_snapshots!(editable_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?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
+2
ファイルの表示
@@ -10,6 +10,7 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -32,6 +33,7 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+8 -21
ファイルの表示
@@ -1,6 +1,5 @@
class TagVersionsController < ApplicationController class TagVersionsController < ApplicationController
def index def index
locale = resolve_locale!
tag_id = params[:id].presence tag_id = params[:id].presence
page = (params[:page].presence || 1).to_i page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i limit = (params[:limit].presence || 20).to_i
@@ -19,8 +18,7 @@ class TagVersionsController < ApplicationController
SQL SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at', 'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names', 'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count count = q.except(:select, :order, :limit, :offset).count
@@ -29,20 +27,18 @@ class TagVersionsController < ApplicationController
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count: } render json: { versions: serialise_versions(versions), count: }
end end
private private
def serialise_versions rows, language_code def serialise_versions rows
user_ids = rows.map(&:created_by_user_id).compact.uniq user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
rows.map do |row| rows.map do |row|
current_names = names_for_locale(row.names, language_code) cur_aliases = split_values(row.aliases)
previous_names = names_for_locale(row.attributes['prev_names'], language_code) prev_aliases = split_values(row.attributes['prev_aliases'])
cur_aliases = current_names.reject { _1['primary_flg'] }.map { _1['name'] }
prev_aliases = previous_names.reject { _1['primary_flg'] }.map { _1['name'] }
cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) 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']) prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids'])
@@ -51,23 +47,21 @@ class TagVersionsController < ApplicationController
tags_by_id = tags_by_id =
Tag Tag
.includes(:tag_names, :materials, tag_names: :wiki_page) .includes(:tag_name, :materials, { tag_name: :wiki_page })
.where(id: all_parent_tag_ids) .where(id: all_parent_tag_ids)
.index_by(&:id) .index_by(&:id)
parent_tags = parent_tags =
build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id)
.map do |h| .map do |h|
{ tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:), { tag: TagRepr.base(tags_by_id[h[:tag_id]]),
type: h[:type] } type: h[:type] }
end end
{ tag_id: row.tag_id, { tag_id: row.tag_id,
version_no: row.version_no, version_no: row.version_no,
event_type: row.event_type, event_type: row.event_type,
names: row.names, name: { current: row.name, prev: row.attributes['prev_name'] },
name: { current: current_names.find { _1['primary_flg'] }&.fetch('name'),
prev: previous_names.find { _1['primary_flg'] }&.fetch('name') },
category: { current: row.category, prev: row.attributes['prev_category'] }, category: { current: row.category, prev: row.attributes['prev_category'] },
deprecated_at: { current: row.deprecated_at&.iso8601, deprecated_at: { current: row.deprecated_at&.iso8601,
prev: row.attributes['prev_deprecated_at']&.iso8601 }, prev: row.attributes['prev_deprecated_at']&.iso8601 },
@@ -97,12 +91,5 @@ class TagVersionsController < ApplicationController
def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) 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) : Array(names)
values.select { _1['language_code'] == language_code }
rescue JSON::ParserError
[]
end
def split_parent_tag_ids(values) = split_values(values).map(&:to_i) def split_parent_tag_ids(values) = split_values(values).map(&:to_i)
end end
+179 -502
ファイルの表示
@@ -5,8 +5,6 @@ require 'set'
class TagsController < ApplicationController class TagsController < ApplicationController
def index def index
locale = resolve_locale!
post_id = params[:post] post_id = params[:post]
name = params[:name].presence name = params[:name].presence
@@ -36,161 +34,49 @@ class TagsController < ApplicationController
offset = (page - 1) * limit offset = (page - 1) * limit
tags = q =
if post_id.present? if post_id.present?
Tag Tag.joins(:posts, :tag_name)
.joins(:posts, :tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
posts: { id: post_id })
else else
Tag Tag.joins(:tag_name)
.joins(:tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
end end
.includes(:tag_name, :materials, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present?
external_tags = q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
if post_id.present? q = q.where(category:) if category
ExternalTag.joins(:posts).where(posts: { id: post_id }) q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
else q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
ExternalTag.all q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
end 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]
if name q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
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_given
if deprecated q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
tags = tags.where.not(deprecated_at: nil)
external_tags = external_tags.none
else
tags = tags.where(deprecated_at: nil)
end
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 = sort_sql =
if order[0] == 'category' case order[0]
'CASE category ' + when 'name'
'tag_names.name'
when 'category'
'CASE tags.category ' +
"WHEN 'deerjikist' THEN 0 " + "WHEN 'deerjikist' THEN 0 " +
"WHEN 'meme' THEN 1 " + "WHEN 'meme' THEN 1 " +
"WHEN 'character' THEN 2 " + "WHEN 'character' THEN 2 " +
"WHEN 'general' THEN 3 " + "WHEN 'general' THEN 3 " +
"WHEN 'material' THEN 4 " + "WHEN 'material' THEN 4 " +
"WHEN 'meta' THEN 5 " + "WHEN 'meta' THEN 5 " +
"WHEN 'nico' THEN 6 " + "WHEN 'nico' THEN 6 END"
'END'
else else
order[0] "tags.#{ order[0] }"
end end
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset(offset)
.to_a
connection = ApplicationRecord.connection render json: { tags: TagRepr.many(tags), count: q.size }
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 end
def with_depth def with_depth
@@ -198,8 +84,7 @@ class TagsController < ApplicationController
parent_tag_id = nil if parent_tag_id <= 0 parent_tag_id = nil if parent_tag_id <= 0
material_filter = material_filter_param(default: 'any') material_filter = material_filter_param(default: 'any')
locale = resolve_locale! graph = build_with_depth_graph(material_filter)
graph = build_with_depth_graph(material_filter, locale.language_code)
tag_ids = tag_ids =
if parent_tag_id if parent_tag_id
@@ -214,223 +99,137 @@ class TagsController < ApplicationController
end end
def autocomplete def autocomplete
locale = resolve_locale!
q = params[:q].to_s.strip.sub(/\Anot:/i, '') q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true) with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true) present_only = bool?(:present, default: true)
alias_rows = alias_rows =
TagName TagName
.where(language_code: locale.language_code, primary_flg: false) .where('name LIKE ?', "#{ q }%")
.where('name LIKE ?', prefix) .where.not(canonical_id: nil)
.pluck(:tag_id, :name) .pluck(:canonical_id, :name)
matched_alias_by_tag_id = { } matched_alias_by_tag_name_id = { }
tag_ids = [] canonical_ids = []
alias_rows.each do |tag_id, alias_name|
tag_ids << tag_id alias_rows.each do |canonical_id, alias_name|
matched_alias_by_tag_id[tag_id] ||= alias_name canonical_ids << canonical_id
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end end
base = base = Tag.joins(:tag_name)
Tag .includes(:tag_name, :materials, tag_name: :wiki_page)
.joins(:tag_names) .where(deprecated_at: nil)
.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 base = base.where('tags.post_count > 0') if present_only
canonical_hit = base.where('tag_names.name LIKE ?', prefix) canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq)) tags =
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
internal_rows = tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
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])
}
return render json: internal_rows unless with_nico render json: tags.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
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 end
def show def show
locale = resolve_locale! tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
tag = .find_by(id: params[:id])
Tag if tag
.joins(:tag_names) render json: TagRepr.base(tag)
.includes(:tag_names, :materials, tag_names: :wiki_page) else
.find_by(id: params[:id], head :not_found
tag_names: { language_code: locale.language_code, primary_flg: true }) end
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 end
def show_by_name def show_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
locale = resolve_locale! tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
tag = .find_by(tag_names: { name: })
Tag if tag
.joins(:tag_names) render json: TagRepr.base(tag)
.includes(:tag_names, :materials, tag_names: :wiki_page) else
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) head :not_found
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag end
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 end
def deerjikists def deerjikists
locale = resolve_locale! tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
tag = .find_by(id: params[:id])
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 return head :not_found unless tag
render json: { tag: TagRepr.base(tag, language_code: locale.language_code), render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many( deerjikists: DeerjikistRepr.many(tag.deerjikists) }
tag.deerjikists,
language_code: locale.language_code) }
end end
def deerjikists_by_name def deerjikists_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
locale = resolve_locale! tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
tag = .find_by(tag_names: { name: })
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 return head :not_found unless tag
render json: { tag: TagRepr.base(tag, language_code: locale.language_code), render json: { tag: TagRepr.base(tag),
deerjikists: DeerjikistRepr.many( deerjikists: DeerjikistRepr.many(tag.deerjikists) }
tag.deerjikists,
language_code: locale.language_code) }
end end
def update_deerjikists def update_deerjikists
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale! tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page)
tag = .find_by(id: params[:id])
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 return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag.lock! tag.deerjikists = []
params[:_json].each.with_index do |item, i|
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq platform = item[:platform]
row_indexes_by_key = rows_by_key(rows) code = normalise_deerjikist_code(platform, item[:code])
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys) deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
current_deerjikists = locked_deerjikists.filter { |deerjikist| deerjikist.tag = tag
deerjikist.tag_id == tag.id render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
}
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? raise ActiveRecord::Rollback if performed?
end end
end end
return if performed? return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists, render json: DeerjikistRepr.many(tag.reload.deerjikists)
language_code: locale.language_code)
end end
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return render_bad_request('name は必須です.') if name.blank?
locale = resolve_locale!
material_filter = material_filter_param(default: 'any') material_filter = material_filter_param(default: 'any')
tag = tag = Tag.joins(:tag_name)
Tag .includes(:tag_name, :materials, tag_name: :wiki_page)
.joins(:tag_names) .find_by(tag_names: { name: })
.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 return head :not_found unless tag
graph = build_with_depth_graph(material_filter, locale.language_code) graph = build_with_depth_graph(material_filter)
render json: build_tag_children(tag, graph:, language_code: locale.language_code) render json: build_tag_children(tag, graph:)
end end
def update_all def update_all
@@ -446,38 +245,39 @@ class TagsController < ApplicationController
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
locale = resolve_locale! return unless validate_tag_rename(tag, name)
return unless validate_tag_rename(locale, tag, name)
alias_names = params[:aliases].to_s.split.uniq alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated) deprecated = bool?(:deprecated)
if category == 'nico' if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
ApplicationRecord.transaction do 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(locale.language_code) old_name = tag.name
name_changed = name != old_name name_changed = name != old_name
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed wiki_page = tag.tag_name.wiki_page if name_changed
if tag.deprecated? == deprecated if tag.deprecated? == deprecated
tag.update!(category:) tag.update!(category:)
else else
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil) tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
end end
rename_tag_name!(locale, tag, name) if name_changed rename_tag_name!(tag, name) if name_changed
alias_names << old_name if name_changed && old_name.present? alias_names << old_name if name_changed
alias_names.delete(name) alias_names.delete(name)
update_aliases!(locale, tag, alias_names) update_aliases!(tag, alias_names)
update_parent_tags!(locale, tag, parent_names) update_parent_tags!(tag, parent_names)
tag.reload tag.reload
@@ -485,20 +285,17 @@ class TagsController < ApplicationController
tag, tag,
event_type: :update, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: locale.language_code,
name_changed:, name_changed:,
wiki_page:) wiki_page:)
end end
render json: TagRepr.base(tag.reload, language_code: locale.language_code) render json: TagRepr.base(tag.reload)
end end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
name = params[:name].presence name = params[:name].presence
category = params[:category].presence category = params[:category].presence
deprecated_given = params.key?(:deprecated) deprecated_given = params.key?(:deprecated)
@@ -506,21 +303,24 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
return unless validate_tag_rename(locale, tag, name) if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if category.present? && category == 'nico' return unless validate_tag_rename(tag, name)
if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
ApplicationRecord.transaction do 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(locale.language_code) old_name = tag.name
name_changed = name.present? && name != old_name name_changed = name.present? && name != old_name
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed wiki_page = tag.tag_name.wiki_page if name_changed
rename_tag_name!(locale, tag, name) if name_changed rename_tag_name!(tag, name) if name_changed
tag.update!(category:) if category.present? tag.update!(category:) if category.present?
if deprecated_given && tag.deprecated? != deprecated if deprecated_given && tag.deprecated? != deprecated
tag.update!(deprecated_at: deprecated ? Time.current : nil) tag.update!(deprecated_at: deprecated ? Time.current : nil)
@@ -532,12 +332,11 @@ class TagsController < ApplicationController
tag, tag,
event_type: :update, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: locale.language_code,
name_changed:, name_changed:,
wiki_page:) wiki_page:)
end end
render json: TagRepr.base(tag.reload, language_code: locale.language_code) render json: TagRepr.base(tag.reload)
end end
private private
@@ -549,7 +348,7 @@ class TagsController < ApplicationController
value value
end end
def build_with_depth_graph material_filter, language_code def build_with_depth_graph material_filter
children_by_parent_id = Hash.new { |h, k| h[k] = [] } children_by_parent_id = Hash.new { |h, k| h[k] = [] }
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
@@ -564,13 +363,10 @@ class TagsController < ApplicationController
material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set
tags_by_id = tags_by_id = Tag.joins(:tag_name)
Tag .where(id: tag_ids)
.joins(:tag_names) .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at')
.where(id: tag_ids, .each_with_object({ }) do |(id, name, category, deprecated_at), h|
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?, h[id] = { name:, category:, deprecated: deprecated_at.present?,
has_material: material_tag_ids.include?(id) } has_material: material_tag_ids.include?(id) }
end end
@@ -702,33 +498,34 @@ class TagsController < ApplicationController
children: [] } children: [] }
end end
def build_tag_children tag, graph: nil, language_code: 'ja' def build_tag_children tag, graph: nil
material = tag.materials.first material = tag.materials.first
tag_graph = graph && graph[:tags_by_id][tag.id] tag_graph = graph && graph[:tags_by_id][tag.id]
material = material =
nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter]) nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter])
children = tag.children.sort_by { _1.name(language_code) } children = tag.children.sort_by(&:name)
if graph if graph
children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) } children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) }
end end
TagRepr.base(tag, language_code:).merge( TagRepr.base(tag).merge(
children: children.map { build_tag_children(_1, graph:, language_code:) }, children: children.map { build_tag_children(_1, graph:) },
has_material: tag_graph ? tag_graph[:has_material] : material.present?, has_material: tag_graph ? tag_graph[:has_material] : material.present?,
material: material && MaterialRepr.base( material: material && MaterialRepr.base(material, host: request.base_url))
material,
host: request.base_url,
language_code:))
end end
def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja', def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
name_changed: false, wiki_page: nil if tag.nico?
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed return unless name_changed
wiki_page ||= tag.tag_name('ja')&.wiki_page wiki_page ||= tag.tag_name.wiki_page
return unless wiki_page&.wiki_versions&.exists? return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!( WikiVersionRecorder.record!(
@@ -737,134 +534,109 @@ class TagsController < ApplicationController
created_by_user:) created_by_user:)
end end
def validate_tag_rename locale, tag, name def validate_tag_rename tag, name
return true if name.blank? || name == tag.name(locale.language_code) return true if name.blank? || name == tag.name
system_tag_names = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ'] if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
if TagName.exists?(tag_id: tag.id,
language_code: 'ja',
primary_flg: true,
name: system_tag_names)
render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
return false return false
end end
target_tag_name = TagName.find_by(language_code: locale.language_code, name:) target_tag_name = TagName.with_discarded.find_by(name:)
return true unless target_tag_name&.primary_flg return true if target_tag_name.nil?
return true if target_tag_name.canonical_id?
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
false false
end end
def rename_tag_name! locale, tag, name def rename_tag_name! tag, name
return if name == tag.name(locale.language_code) return if name == tag.name
current_tag_name = tag.tag_name(locale.language_code) current_tag_name = tag.tag_name
target_tag_name = TagName.find_by(language_code: locale.language_code, name:) target_tag_name = TagName.with_discarded.find_by(name:)
unless target_tag_name if target_tag_name.nil?
if current_tag_name current_tag_name.update!(name:)
current_tag_name.update!(name:)
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 return
end 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 end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name: def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
old_owner_tag = promoted_tag_name.tag old_owner_tag = promoted_tag_name.canonical&.tag
if old_owner_tag && old_owner_tag != tag if old_owner_tag && old_owner_tag != tag
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end end
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) promoted_tag_name.undiscard! if promoted_tag_name.discarded?
current_tag_name.association(:wiki_page).reset promoted_tag_name.update!(canonical: nil)
current_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) TagName.with_discarded
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true) .where(canonical_id: current_tag_name.id)
TagName.where(tag:,
language_code: current_tag_name.language_code,
primary_flg: false)
.where.not(id: promoted_tag_name.id) .where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name| .find_each do |alias_tag_name|
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) alias_tag_name.update!(canonical: promoted_tag_name)
end end
tag.update!(tag_name_id: promoted_tag_name.id) current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name)
tag.update!(tag_name: promoted_tag_name)
current_tag_name.association(:wiki_page).reset current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false) current_tag_name.reload.update!(canonical: promoted_tag_name)
return unless old_owner_tag && old_owner_tag != tag return unless old_owner_tag && old_owner_tag != tag
record_tag_version!(old_owner_tag.reload, event_type: :update, record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user)
created_by_user: current_user,
language_code: current_tag_name.language_code)
end end
def update_aliases! locale, tag, alias_names def update_aliases! tag, alias_names
alias_names = alias_names.uniq alias_names = alias_names.uniq
affected_tags = [tag] affected_tags = [tag]
primary_tag_name = TagName.find_by!(tag:, current_aliases = tag.tag_name.aliases.to_a
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| current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name) next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.tag affected_tags << alias_tag_name.canonical&.tag
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name) alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name&.tag affected_tags << alias_tag_name.canonical&.tag
end end
affected_tags.compact.uniq.each do |affected_tag| 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 end
current_aliases.each do |alias_tag_name| current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name) next if alias_names.include?(alias_tag_name.name)
alias_tag_name.update!(canonical_id: nil, tag_id: nil) alias_tag_name.update!(canonical: nil)
end end
alias_names.each do |alias_name| alias_names.each do |alias_name|
alias_tag_name = TagName.find_or_initialize_by( alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
language_code: locale.language_code, alias_tag_name.update!(canonical: tag.tag_name)
name: alias_name)
alias_tag_name.script_code = locale.script_code
alias_tag_name.update!(tag:, primary_flg: false, canonical_id: primary_tag_name.id)
end end
affected_tags.compact.uniq.each do |affected_tag| affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update, record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user)
created_by_user: current_user,
language_code: locale.language_code)
end end
end end
def update_parent_tags! locale, tag, parent_names def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(locale, parent_names, parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_tagme: false, with_no_deerjikist: false,
with_no_deerjikist: false) deny_nico: true)
old_parent_tags = tag.parents.to_a old_parent_tags = tag.parents.to_a
@@ -881,7 +653,6 @@ class TagsController < ApplicationController
end end
def normalise_deerjikist_code platform, code def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@' return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }" url = "https://www.youtube.com/#{ code }"
@@ -898,84 +669,6 @@ class TagsController < ApplicationController
nil nil
end 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 def render_deerjikist_form_record_invalid deerjikist, index
fields = { } fields = { }
@@ -994,20 +687,4 @@ class TagsController < ApplicationController
render_validation_error fields: render_validation_error fields:
end 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 end
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ class TheatreSkipEventsController < ApplicationController
events = events =
TheatreSkipEvent TheatreSkipEvent
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_names) .includes(:post, tags: :tag_name)
.order(created_at: :desc) .order(created_at: :desc)
.limit(limit) .limit(limit)
@@ -14,7 +14,7 @@ class TheatreSkipEventsController < ApplicationController
{ id: event.id, { id: event.id,
theatre_id: event.theatre_id, theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url }, post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name('ja') } }, tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position, programme_position: event.programme_position,
created_at: event.created_at } created_at: event.created_at }
} }
+1 -5
ファイルの表示
@@ -94,11 +94,7 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| tag_name = TagName.find_undiscard_or_create_by!(name: title)
name.script_code = 'Jpan'
name.primary_flg = true
name.auto_generated = false
end
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
-24
ファイルの表示
@@ -1,24 +0,0 @@
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
-3
ファイルの表示
@@ -1,3 +0,0 @@
class Language < ApplicationRecord
has_many :locales, foreign_key: :language_code, primary_key: :code
end
-24
ファイルの表示
@@ -1,24 +0,0 @@
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
def self.nipponese = Locale.find('ja')
private
def generate_tag_names!
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.name('ja')),
script_code:,
primary_flg: true,
# TODO: 公証実装したら書く.
# auto_generated: true,
canonical_id: nil)
end
end
end
+19 -5
ファイルの表示
@@ -1,10 +1,24 @@
class NicoTagRelation < ApplicationRecord class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag, belongs_to :nico_tag, class_name: 'Tag'
class_name: 'ExternalTag', belongs_to :tag, class_name: 'Tag'
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 :nico_tag_id, presence: true
validates :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 end
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord class NicoTagVersion < ApplicationRecord
include VersionRecord include VersionRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions belongs_to :tag
validates :name, presence: true validates :name, presence: true
end end
+9 -50
ファイルの表示
@@ -55,27 +55,25 @@ class Post < ApplicationRecord
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :tags, through: :post_tags 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 :active_tags, -> { where(tags: { deprecated_at: nil }) }, has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :post_tags, through: :active_post_tags, source: :tag
source: :tag
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions has_many :post_versions
has_many :gekanator_guessed_games, has_many :gekanator_guessed_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :guessed_post_id, foreign_key: :guessed_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :guessed_post inverse_of: :guessed_post
has_many :gekanator_correct_games, has_many :gekanator_correct_games,
class_name: 'GekanatorGame', class_name: 'GekanatorGame',
foreign_key: :correct_post_id, foreign_key: :correct_post_id,
dependent: :delete_all, dependent: :delete_all,
inverse_of: :correct_post inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications, has_many :parent_post_implications,
@@ -92,9 +90,6 @@ class Post < ApplicationRecord
inverse_of: :parent_post inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :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 has_one_attached :thumbnail
attribute :version_no, :integer, default: 1 attribute :version_no, :integer, default: 1
@@ -126,14 +121,14 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names language_code = 'ja' def snapshot_tag_names
post_tags post_tags
.joins(tag: :tag_names) .kept
.where(tag_names: { language_code:, primary_flg: true }) .joins(tag: :tag_name)
.includes(:sections, tag: :tag_names) .includes(:sections, tag: :tag_name)
.order('tag_names.name') .order('tag_names.name')
.map do |post_tag| .map do |post_tag|
name = post_tag.tag.name(language_code) name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms) sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty? next name if sections.empty?
@@ -142,42 +137,6 @@ class Post < ApplicationRecord
end end
end end
def self.tag_snapshot_literal tag
sections = tag.fetch('sections', []).map do |sec|
begin_ms = sec.fetch('begin_ms')
end_ms = sec['end_ms']
"[#{ Post.ms_to_time(begin_ms) }-#{ end_ms ? Post.ms_to_time(end_ms) : '' }]"
end
"#{ tag.fetch('name') }#{ sections.join }"
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 def self.section_literal section
end_ms = end_ms =
section.end_ms ? Post.ms_to_time(section.end_ms) : '' section.end_ms ? Post.ms_to_time(section.end_ms) : ''
-4
ファイルの表示
@@ -1,4 +0,0 @@
class PostExternalTag < ApplicationRecord
belongs_to :post
belongs_to :external_tag, counter_cache: :post_count
end
+21 -1
ファイルの表示
@@ -1,7 +1,14 @@
class PostTag < ApplicationRecord class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post belongs_to :post
belongs_to :tag, counter_cache: :post_count belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true 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', has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id], foreign_key: [:post_id, :tag_id],
@@ -11,5 +18,18 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { scope: :tag_id } 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
end end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post belongs_to :post
belongs_to :tag belongs_to :tag
belongs_to :post_tag, foreign_key: [:post_id, :tag_id], belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id], primary_key: [:post_id, :tag_id],
inverse_of: :sections, inverse_of: :sections,
optional: true optional: true
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
-3
ファイルの表示
@@ -1,3 +0,0 @@
class Script < ApplicationRecord
;
end
+74 -74
ファイルの表示
@@ -2,6 +2,8 @@ require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError class NicoTagNormalisationError < ArgumentError
; ;
end end
@@ -26,12 +28,15 @@ class Tag < ApplicationRecord
end end
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :posts, through: :post_tags 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 :reversed_nico_tag_relations, has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation', class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
foreign_key: :tag_id,
dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
@@ -49,22 +54,32 @@ class Tag < ApplicationRecord
has_many :materials has_many :materials
has_many :tag_versions has_many :tag_versions
has_many :nico_tag_versions
has_many :tag_names belongs_to :tag_name
delegate :wiki_page, to: :tag_name
attribute :version_no, :integer, default: 1 attribute :version_no, :integer, default: 1
delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true
enum :category, deerjikist: 'deerjikist', enum :category, deerjikist: 'deerjikist',
meme: 'meme', meme: 'meme',
character: 'character', character: 'character',
general: 'general', general: 'general',
material: 'material', material: 'material',
nico: 'nico',
meta: 'meta' meta: 'meta'
validates :category, presence: true, inclusion: { in: Tag.categories.keys } validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists validate :category_must_be_deerjikist_with_deerjikists
validate :tag_names_must_be_valid validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
CATEGORY_PREFIXES = { CATEGORY_PREFIXES = {
'general:' => :general, 'general:' => :general,
@@ -78,20 +93,8 @@ class Tag < ApplicationRecord
'mtr:' => :material, 'mtr:' => :material,
'meta:' => :meta }.freeze 'meta:' => :meta }.freeze
SYSTEM_TAG_NAMES = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ', 'YouTube'].freeze def name= val
(self.tag_name ||= build_tag_name).name = val
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 end
def deprecated? = deprecated_at? def deprecated? = deprecated_at?
@@ -102,20 +105,19 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
def self.no_deerjikist = def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta) def self.video = find_or_create_by_tag_name!('動画', category: :meta)
def self.video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta) def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
def self.niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta) def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta)
def self.youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta)
def self.normalise_tags! locale, tag_names, def self.normalise_tags! tag_names, with_tagme: true,
with_tagme: true, with_no_deerjikist: true,
with_no_deerjikist: true, deny_nico: true,
deny_deprecated: false, deny_deprecated: false,
with_sections: false with_sections: false
if tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
@@ -141,11 +143,11 @@ class Tag < ApplicationRecord
raise SectionLiteralParseError.new(raw_name, raw_name) raise SectionLiteralParseError.new(raw_name, raw_name)
end end
name = TagName.canonicalise(locale, name).first name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated? if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)] raise DeprecatedTagNormalisationError, [tag.name]
end end
tag.update!(category: cat) if cat && tag.category != cat tag.update!(category: cat) if cat && tag.category != cat
@@ -231,23 +233,13 @@ class Tag < ApplicationRecord
[left_end_ms, right_end_ms].max [left_end_ms, right_end_ms].max
end end
def self.find_or_create_by_tag_name! locale, name, category: def self.find_or_create_by_tag_name! name, category:
language_code = locale.language_code tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
name = name.to_s.strip tn = tn.canonical if tn.canonical_id?
tn = TagName.find_or_create_by!(language_code:, name:) do Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
_1.script_code = locale.script_code t.category = category
_1.primary_flg = true
end end
tag = tn.tag
return tag if tag
tag = Tag.create!(tag_name_id: tn.id, category:)
tn.update!(tag:)
tag.association(:tag_names).reset
tag
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
retry retry
end end
@@ -267,28 +259,30 @@ class Tag < ApplicationRecord
TagVersioning.ensure_snapshot!(source_tag, created_by_user:) TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.find_each do |source_pt| source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id post_id = source_pt.post_id
affected_post_ids << post_id affected_post_ids << post_id
source_pt.destroy! source_pt.discard_by!(created_by_user)
unless PostTag.exists?(post_id:, tag: target_tag) unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag)
end end
end end
source_tag_name = source_tag.tag_name('ja') source_tag_name = source_tag.tag_name
if source_tag_name.wiki_page.present? if source_tag_name.wiki_page.present?
raise ActiveRecord::RecordInvalid.new(source_tag_name) raise ActiveRecord::RecordInvalid.new(source_tag_name)
end end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, source_tag.discard!
updated_at: Time.current)
source_tag.destroy!
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, if source_tag.nico?
updated_at: Time.current) 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:) TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end end
@@ -299,33 +293,33 @@ class Tag < ApplicationRecord
end end
# 投稿件数を再集計 # 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count) target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
end end
target_tag.reload target_tag.reload
end end
def snapshot_aliases(language_code) def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
end
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
def snapshot_linked_tag_names language_code def snapshot_linked_tag_names
linked_tags linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
.joins(:tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.order('tag_names.name')
.pluck('tag_names.name')
end end
private private
def tag_names_must_be_valid def nico_tag_name_must_start_with_nico
tag_names.each do |tag_name| n = name.to_s
next if tag_name.valid? if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end
end
tag_name.errors[:name].each { errors.add :name, _1 } def tag_name_must_be_canonical
if tag_name&.canonical_id?
errors.add :tag_name, 'tag_names へは実体を示す必要があります.'
end end
end end
@@ -360,4 +354,10 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end end
+22 -37
ファイルの表示
@@ -1,54 +1,45 @@
class TagName < ApplicationRecord class TagName < ApplicationRecord
belongs_to :language, foreign_key: :language_code, primary_key: :code include MyDiscard
belongs_to :tag, optional: true
has_one :tag
has_one :wiki_page has_one :wiki_page
validates :name, presence: true, belongs_to :canonical, class_name: 'TagName', optional: true
length: { maximum: 255 }, has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id
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 :alias_name_must_not_have_prefix
validate :alias_must_not_have_wiki_page validate :canonical_must_not_be_present_with_tag_or_wiki_page
validate :name_must_be_sanitised validate :name_must_be_sanitised
validate :name_mustnt_start_with_nico
def primary? = primary_flg def self.canonicalise names
def self.canonicalise locale, names
names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?)
return [] if names.blank? return [] if names.blank?
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) tns = TagName.includes(:canonical).where(name: names).index_by(&:name)
names.map { |name| names.map { |name| tns[name]&.canonical&.name || name }.uniq
tag_name = tns[name]
if !tag_name || tag_name.primary?
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, name
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end end
private private
def canonical_must_be_canonical
if canonical&.canonical_id?
errors.add :canonical, 'canonical は実体を示す必要があります.'
end
end
def alias_name_must_not_have_prefix def alias_name_must_not_have_prefix
if !(primary?) && name.to_s.include?(':') if canonical_id? && name.to_s.include?(':')
errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.'
end end
end end
def alias_must_not_have_wiki_page def canonical_must_not_be_present_with_tag_or_wiki_page
if !(primary?) && wiki_page if canonical_id? && (tag || wiki_page)
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.' errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.'
end end
end end
@@ -57,10 +48,4 @@ class TagName < ApplicationRecord
errors.add :name, '名前に使用できない文字が含まれてゐます.' errors.add :name, '名前に使用できない文字が含まれてゐます.'
end end
end end
def name_mustnt_start_with_nico
if name.to_s.downcase.start_with?('nico:')
errors.add :name, 'タグの命名規則に反してゐます.'
end
end
end end
+21 -30
ファイルの表示
@@ -10,44 +10,35 @@ class TagNameSanitisationRule < ApplicationRecord
validate :source_pattern_must_be_regexp validate :source_pattern_must_be_regexp
class << self class << self
def sanitise(name) def sanitise(name) =
rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) } rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) }
end
def apply! def apply!
Language.find_each do |language| TagName.find_each do |tn|
TagName.where(language:).find_each do |tn| name = sanitise(tn.name)
name = sanitise(tn.name) next if name == tn.name
next if name == tn.name
TagName.transaction do TagName.transaction do
existing_tn = TagName.find_by(language:, name:) existing_tn = TagName.find_by(name:)
if existing_tn if existing_tn
unless existing_tn.primary_flg existing_tn = existing_tn.canonical || existing_tn
existing_tn = TagName.find_by!(language:, next if existing_tn.id == tn.id
tag_id: existing_tn.tag_id,
primary_flg: true)
end
next if existing_tn.id == tn.id
existing_tag = existing_tn.tag existing_tag = Tag.find_by(tag_name_id: existing_tn.id)
source_tag = tn.tag source_tag = Tag.find_by(tag_name_id: tn.id)
if existing_tag if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_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)
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 end
tn.discard!
# TagName 側の自動サニタイズを回避 next
tn.update_columns(name:, updated_at: Time.current)
end end
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end end
end end
end end
-1
ファイルの表示
@@ -11,6 +11,5 @@ class TagVersion < ApplicationRecord
meta: 'meta' }, validate: true meta: 'meta' }, validate: true
validates :name, presence: true validates :name, presence: true
validates :names, presence: true
validates :category, presence: true validates :category, presence: true
end end
+4 -5
ファイルの表示
@@ -6,12 +6,11 @@ module DeerjikistRepr
module_function module_function
def base deerjikist, language_code: 'ja' def base deerjikist
deerjikist.as_json(BASE).merge( deerjikist.as_json(BASE)
tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code:))
end end
def many deerjikists, language_code: 'ja' def many deerjikists
deerjikists.map { |d| base(d, language_code:) } deerjikists.map { |d| base(d) }
end end
end end
-20
ファイルの表示
@@ -1,20 +0,0 @@
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
+13 -14
ファイルの表示
@@ -12,15 +12,14 @@ module MaterialRepr
module_function module_function
def base material, host:, language_code: 'ja' def base material, host:
material.as_json(BASE).merge( material.as_json(BASE).merge(
tag: material.tag && TagRepr.base(material.tag, language_code:),
file: if material.file.attached? file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url( Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:) material.file, host:)
end, end,
thumbnail: thumbnail_url(material, host:), thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:), thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material), thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material), media_kind: media_kind(material),
file_byte_size: material.file_byte_size, file_byte_size: material.file_byte_size,
@@ -28,11 +27,11 @@ module MaterialRepr
export_items: export_items(material)) export_items: export_items(material))
end end
def many materials, host:, language_code: 'ja' def many materials, host:
materials.map { |m| base(m, host:, language_code:) } materials.map { |m| base(m, host:) }
end end
def list material, host:, language_code: 'ja' def list material, host:
{ id: material.id, { id: material.id,
version_no: material.version_no, version_no: material.version_no,
url: material.url, url: material.url,
@@ -41,9 +40,9 @@ module MaterialRepr
source_path: material.source_path, source_path: material.source_path,
source_file_id: material.source_file_id, source_file_id: material.source_file_id,
normalized_source_key: material.normalized_source_key, normalized_source_key: material.normalized_source_key,
tag: compact_tag(material.tag, language_code:), tag: compact_tag(material.tag),
thumbnail: thumbnail_url(material, host:), thumbnail: thumbnail_url(material, host:),
thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:), thumbnail_fallback_text: thumbnail_fallback_text(material),
thumbnail_fallback_kind: thumbnail_fallback_kind(material), thumbnail_fallback_kind: thumbnail_fallback_kind(material),
media_kind: media_kind(material), media_kind: media_kind(material),
content_type: material.content_type, content_type: material.content_type,
@@ -54,8 +53,8 @@ module MaterialRepr
export_items: export_items(material) } export_items: export_items(material) }
end end
def list_many materials, host:, language_code: 'ja' def list_many materials, host:
materials.map { |m| list(m, host:, language_code:) } materials.map { |m| list(m, host:) }
end end
def export_paths material def export_paths material
@@ -80,8 +79,8 @@ module MaterialRepr
material.thumbnail, host:) material.thumbnail, host:)
end end
def thumbnail_fallback_text material, language_code: def thumbnail_fallback_text material
material.tag&.name(language_code) || material.created_at&.strftime('%Y-%m-%d') material.tag&.name || material.created_at&.strftime('%Y-%m-%d')
end end
def thumbnail_fallback_kind material def thumbnail_fallback_kind material
@@ -99,11 +98,11 @@ module MaterialRepr
'file_other' 'file_other'
end end
def compact_tag tag, language_code: def compact_tag tag
return nil unless tag return nil unless tag
{ id: tag.id, { id: tag.id,
name: tag.name(language_code), name: tag.name,
category: tag.category, category: tag.category,
deprecated_at: tag.deprecated_at } deprecated_at: tag.deprecated_at }
end end
+13 -22
ファイルの表示
@@ -17,23 +17,23 @@ module PostRepr
module_function module_function
def base post, current_user = nil, host: nil, language_code: 'ja' def base post, current_user = nil, host: nil
json = json =
if host.present? if host.present?
common(post, host:) common(post, host:)
else else
common(post) common(post)
end end
json['tags'] = tag_json(post, language_code:) json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user) json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false json['viewed'] = current_user ? current_user.viewed?(post) : false
json json
end end
def detail post, current_user = nil, parent_posts: [], child_posts: [], def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: [], host: nil, language_code: 'ja' sibling_posts: { }, related: [], host: nil
if host.present? if host.present?
base(post, current_user, host:, language_code:).merge( base(post, current_user, host:).merge(
'parent_posts' => cards(parent_posts, host:), 'parent_posts' => cards(parent_posts, host:),
'child_posts' => cards(child_posts, host:), 'child_posts' => cards(child_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts| 'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
@@ -41,7 +41,7 @@ module PostRepr
}, },
'related' => cards(related, host:)) 'related' => cards(related, host:))
else else
base(post, current_user, language_code:).merge( base(post, current_user).merge(
'parent_posts' => cards(parent_posts), 'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts), 'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts| 'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
@@ -86,25 +86,16 @@ module PostRepr
end) end)
end end
def tag_json post, language_code: def tag_json post
internal_tags = post
post .active_post_tags
.post_tags
.reject { _1.tag.deprecated? } .reject { _1.tag.deprecated? }
.sort_by { TagRepr.name_for(_1.tag, language_code) } .sort_by { _1.tag.name }
.map { |post_tag| .map { |post_tag|
TagRepr.inline(post_tag.tag, language_code:).merge( TagRepr.inline(post_tag.tag).merge(
'children' => [], 'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) '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 end
def thumbnail_url post, host: nil def thumbnail_url post, host: nil
+7 -12
ファイルの表示
@@ -3,23 +3,18 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
module_function module_function
def base tag, language_code: 'ja' def base tag
tag.as_json(BASE).merge(name: name_for(tag, language_code), tag.as_json(BASE).merge(aliases: tag.snapshot_aliases,
aliases: tag.snapshot_aliases(language_code), parents: tag.parents.map { _1.as_json(BASE) })
parents: tag.parents.map { inline(_1, language_code:) })
end end
def inline tag, language_code: 'ja' def inline tag
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: []) tag.as_json(BASE).merge(aliases: [], parents: [])
end end
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) } def many(tags) = tags.map { |t| base(t) }
def name_for tag, language_code
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
end
end end
+1 -1
ファイルの表示
@@ -31,7 +31,7 @@ class MaterialVersionRecorder < VersionRecorder
{ url: @record.url, { url: @record.url,
parent: @record.parent, parent: @record.parent,
tag: @record.tag, tag: @record.tag,
tag_name: @record.tag&.name('ja'), tag_name: @record.tag&.name,
tag_category: @record.tag&.category, tag_category: @record.tag&.category,
source_kind: @record.source_kind, source_kind: @record.source_kind,
source_uri: @record.source_uri, source_uri: @record.source_uri,
+6 -9
ファイルの表示
@@ -1,22 +1,19 @@
class NicoTagVersionRecorder < VersionRecorder class NicoTagVersionRecorder < VersionRecorder
def self.record! external_tag:, event_type:, created_by_user: def self.record! tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record! new(tag:, event_type:, created_by_user:).record!
end end
def initialize external_tag:, event_type:, created_by_user: def initialize tag:, event_type:, created_by_user:
super(record: external_tag, event_type:, created_by_user:) super(record: tag, event_type:, created_by_user:)
end end
private private
def version_class = NicoTagVersion def version_class = NicoTagVersion
def version_association = :nico_tag_versions def version_association = :nico_tag_versions
def record_key = :external_tag def record_key = :tag
def snapshot_attributes def snapshot_attributes
{ name: "#{ @record.platform }:#{ @record.name }", { name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end end
def tracks_version_no_on_record? = false
end end
+5 -5
ファイルの表示
@@ -6,7 +6,7 @@ class PostBulkCreator
@host = host @host = host
end end
def run locale def run
results = Array.new(@posts.length) results = Array.new(@posts.length)
mutex = Mutex.new mutex = Mutex.new
next_index = 0 next_index = 0
@@ -27,7 +27,7 @@ class PostBulkCreator
break if index >= @posts.length break if index >= @posts.length
attributes = @posts[index] attributes = @posts[index]
results[index] = create_row(locale, actor, attributes, index) results[index] = create_row(actor, attributes, index)
rescue StandardError => e rescue StandardError => e
Rails.logger.error( Rails.logger.error(
"post_bulk_creator_worker_failure #{ { error: e.class.name, "post_bulk_creator_worker_failure #{ { error: e.class.name,
@@ -61,12 +61,12 @@ class PostBulkCreator
private private
def create_row locale, actor, attributes, index def create_row actor, attributes, index
preflight = preflight =
PostCreatePreflight.new( PostCreatePreflight.new(
attributes: attributes, attributes: attributes,
thumbnail: thumbnail_for(index, attributes), thumbnail: thumbnail_for(index, attributes),
host: @host).run(locale) host: @host).run
if preflight[:existing_post_id].present? if preflight[:existing_post_id].present?
return { return {
status: 'skipped', status: 'skipped',
@@ -76,7 +76,7 @@ class PostBulkCreator
post = PostCreator.new( post = PostCreator.new(
actor: actor, actor: actor,
attributes: normalised_attributes(attributes, preflight, index)).create!(locale) attributes: normalised_attributes(attributes, preflight, index)).create!
result = { result = {
status: 'created', status: 'created',
post: { id: post.id } } post: { id: post.id } }
+63 -72
ファイルの表示
@@ -8,50 +8,50 @@ class PostCreatePlan
@existing_tags_by_name = nil @existing_tags_by_name = nil
end end
def build! locale def build!
direct_tag_specs, tag_sections = parse_direct_tag_specs(locale) direct_tag_specs, tag_sections = parse_direct_tag_specs
default_tag_specs = build_default_tag_specs(locale, direct_tag_specs) default_tag_specs = build_default_tag_specs(direct_tag_specs)
snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs) snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs)
preload_existing_tags_by_name!(locale, snapshot_tag_specs.map { _1[:name] }) preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] })
validate_new_tag_specs!(locale, snapshot_tag_specs) validate_new_tag_specs!(snapshot_tag_specs)
post_tag_specs = expand_parent_tag_specs(locale, snapshot_tag_specs) post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs)
video_ms = normalise_video_ms(snapshot_tag_specs) video_ms = normalise_video_ms(snapshot_tag_specs)
validate_video_sections!(video_ms, tag_sections) validate_video_sections!(video_ms, tag_sections)
parent_post_ids = normalise_parent_post_ids parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids) validate_parent_post_ids!(parent_post_ids)
{ url: @attributes[:url], {
title: @attributes[:title].to_s, url: @attributes[:url],
thumbnail_base: @attributes[:thumbnail_base].presence, title: @attributes[:title].to_s,
original_created_from: @attributes[:original_created_from].presence, thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_before: @attributes[:original_created_before].presence, original_created_from: @attributes[:original_created_from].presence,
tags: serialised_tags(direct_tag_specs, tag_sections), original_created_before: @attributes[:original_created_before].presence,
display_tags: display_tags(direct_tag_specs, tag_sections), tags: serialised_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s, display_tags: display_tags(direct_tag_specs, tag_sections),
video_ms: video_ms, duration: @attributes[:duration].to_s,
parent_post_ids: parent_post_ids.join(' '), video_ms: video_ms,
direct_tag_specs: direct_tag_specs, parent_post_ids: parent_post_ids.join(' '),
default_tag_specs: default_tag_specs, direct_tag_specs: direct_tag_specs,
snapshot_tag_specs: snapshot_tag_specs, default_tag_specs: default_tag_specs,
post_tag_specs: post_tag_specs, snapshot_tag_specs: snapshot_tag_specs,
tag_sections: tag_sections, post_tag_specs: post_tag_specs,
normalised_parent_post_ids: parent_post_ids } tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
end end
private private
def tag_names = @attributes[:tags].to_s.split def tag_names = @attributes[:tags].to_s.split
def parse_direct_tag_specs locale def parse_direct_tag_specs
tag_sections = { } tag_sections = { }
direct_tag_specs = [] direct_tag_specs = []
tag_names.each do |raw_name| tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(locale, raw_name) tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name(locale)[tag_name] existing_tag = existing_tags_by_name[tag_name]
if existing_tag&.deprecated? raise Tag::NicoTagNormalisationError if existing_tag&.nico?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name(locale.language_code)] raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
end
direct_tag_specs << { direct_tag_specs << {
name: tag_name, name: tag_name,
@@ -67,7 +67,7 @@ class PostCreatePlan
[merge_tag_specs(direct_tag_specs), tag_sections] [merge_tag_specs(direct_tag_specs), tag_sections]
end end
def parse_raw_tag_name locale, raw_name def parse_raw_tag_name raw_name
name = raw_name.to_s name = raw_name.to_s
prefix, category = prefix, category =
Tag::CATEGORY_PREFIXES.find { Tag::CATEGORY_PREFIXES.find {
@@ -76,7 +76,7 @@ class PostCreatePlan
name = name.sub(/\A#{ prefix }/i, '') name = name.sub(/\A#{ prefix }/i, '')
sections = [] 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] }" name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty? next if match[2].empty? && match[3].empty?
@@ -89,17 +89,17 @@ class PostCreatePlan
raise Tag::SectionLiteralParseError.new(raw_name, raw_name) raise Tag::SectionLiteralParseError.new(raw_name, raw_name)
end end
[resolved_tag_name(locale, name), category&.to_sym, sections] [resolved_tag_name(name), category&.to_sym, sections]
end end
def build_default_tag_specs locale, direct_tag_specs def build_default_tag_specs direct_tag_specs
default_tag_specs = [] default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME } if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << { default_tag_specs << {
name: TAGME_TAG_NAME, name: TAGME_TAG_NAME,
category: :meta } category: :meta }
end end
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) } if direct_tag_specs.none? { deerjikist_tag_spec?(_1) }
default_tag_specs << { default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME, name: NO_DEERJIKIST_TAG_NAME,
category: :meta } category: :meta }
@@ -108,18 +108,17 @@ class PostCreatePlan
default_tag_specs default_tag_specs
end end
def validate_new_tag_specs! locale, specs def validate_new_tag_specs! specs
Array(specs).each do |spec| Array(specs).each do |spec|
next if existing_tags_by_name(locale).key?(spec[:name]) next if existing_tags_by_name.key?(spec[:name])
validate_new_tag_spec!(locale, spec) validate_new_tag_spec!(spec)
end end
end end
def validate_new_tag_spec! locale, spec def validate_new_tag_spec! spec
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code, tag_name = TagName.new(name: spec[:name])
script_code: locale.script_code, primary_flg: true) tag = Tag.new(category: spec[:category], tag_name:)
tag = Tag.new(category: spec[:category])
return if tag_name.valid? && tag.valid? return if tag_name.valid? && tag.valid?
post = Post.new post = Post.new
@@ -134,22 +133,20 @@ class PostCreatePlan
raise ActiveRecord::RecordInvalid, post raise ActiveRecord::RecordInvalid, post
end end
def expand_parent_tag_specs locale, snapshot_tag_specs def expand_parent_tag_specs snapshot_tag_specs
existing_snapshot_tags = snapshot_tag_specs.filter_map do existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] }
existing_tags_by_name(locale)[_1[:name]]
end
expanded_parent_specs = expanded_parent_specs =
Tag.expand_parent_tags(existing_snapshot_tags) Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?) .reject(&:deprecated?)
.map { |tag| .map { |tag|
{ name: tag.name(locale.language_code), {
category: tag.category.to_sym } name: tag.name,
} category: tag.category.to_sym } }
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs) merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end end
def merge_tag_specs specs def merge_tag_specs specs
specs.each_with_object({ }) { |spec, merged| specs.each_with_object({ }) do |spec, merged|
merged[spec[:name]] = merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]] merged[spec[:name]]
@@ -158,40 +155,36 @@ class PostCreatePlan
name: spec[:name], name: spec[:name],
category: spec[:category] } category: spec[:category] }
end end
}.values.sort_by { _1[:name] } end.values.sort_by { _1[:name] }
end end
def existing_tags_by_name locale def existing_tags_by_name
@existing_tags_by_name ||= begin @existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq
TagName.where(language_code: locale.language_code, primary_flg: true, name: names) Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
end end
end end
def preload_existing_tags_by_name! locale, names def preload_existing_tags_by_name! names
wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq
missing_names = wanted_names - existing_tags_by_name(locale).keys missing_names = wanted_names - existing_tags_by_name.keys
return if missing_names.empty? return if missing_names.empty?
existing_tags_by_name(locale).merge!( existing_tags_by_name.merge!(
TagName.where(language_code: locale.language_code, Tag.joins(:tag_name)
primary_flg: true, .where(tag_names: { name: missing_names })
name: missing_names) .index_by(&:name))
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag })
end end
def canonical_tag_name_without_sections locale, raw_name def canonical_tag_name_without_sections raw_name
name, = parse_raw_tag_name(locale, raw_name) name, = parse_raw_tag_name(raw_name)
name name
end end
def deerjikist_tag_spec? locale, spec def deerjikist_tag_spec? spec
return true if spec[:category] == :deerjikist return true if spec[:category] == :deerjikist
existing_tags_by_name(locale)[spec[:name]]&.deerjikist? existing_tags_by_name[spec[:name]]&.deerjikist?
end end
def normalise_parent_post_ids def normalise_parent_post_ids
@@ -269,12 +262,10 @@ class PostCreatePlan
end end
end end
def resolved_tag_name locale, name def resolved_tag_name name
tag_name = TagName.find_by(language_code: locale.language_code, name:) tag_name = TagName.includes(:canonical).find_by(name:)
return name if !(tag_name) || tag_name.primary? return name if tag_name.nil?
TagName.find_by(language_code: locale.language_code, (tag_name.canonical || tag_name).name
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end end
end end
+2 -3
ファイルの表示
@@ -15,10 +15,9 @@ class PostCreatePreflight
@host = host @host = host
end end
def run locale def run
preview = PostImportPreviewer.new.preview_rows( preview = PostImportPreviewer.new.preview_rows(
rows: [preview_row], rows: [preview_row],
locale:,
fetch_metadata: false).first fetch_metadata: false).first
if preview[:existing_post_id].present? if preview[:existing_post_id].present?
return { return {
@@ -54,7 +53,7 @@ class PostCreatePreflight
original_created_from: preview[:attributes]['original_created_from'], original_created_from: preview[:attributes]['original_created_from'],
original_created_before: preview[:attributes]['original_created_before'], original_created_before: preview[:attributes]['original_created_before'],
duration: preview[:attributes]['duration'], duration: preview[:attributes]['duration'],
video_ms: preview[:attributes]['video_ms'] }).build!(locale) video_ms: preview[:attributes]['video_ms'] }).build!
{ {
url: plan[:url], url: plan[:url],
+32 -40
ファイルの表示
@@ -9,7 +9,7 @@ class PostCreator
@field_warnings = { } @field_warnings = { }
end end
def create! locale def create!
thumbnail_attachment = prepare_thumbnail_attachment thumbnail_attachment = prepare_thumbnail_attachment
post = Post.new(title: @attributes[:title].presence, post = Post.new(title: @attributes[:title].presence,
url: @attributes[:url], url: @attributes[:url],
@@ -21,17 +21,14 @@ class PostCreator
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present? post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present?
snapshot_tags = planned_snapshot_tags(locale) snapshot_tags = planned_snapshot_tags
post_tags = planned_post_tags(locale) post_tags = planned_post_tags
sections = planned_sections(locale) sections = planned_sections
TagVersioning.record_tag_snapshots!( TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor)
snapshot_tags, post.video_ms = planned_video_ms
created_by_user: @actor,
language_code: locale.language_code)
post.video_ms = planned_video_ms(locale)
post.save! post.save!
sync_post_tags!(post, post_tags, sections) sync_post_tags!(post, post_tags, sections)
sync_parent_posts!(post, planned_parent_post_ids(locale)) sync_parent_posts!(post, planned_parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor)
end end
post post
@@ -48,73 +45,69 @@ class PostCreator
thumbnail_base: @attributes[:thumbnail_base].presence) thumbnail_base: @attributes[:thumbnail_base].presence)
end end
def planned_snapshot_tags(locale) = planned_create_attributes(locale)[:snapshot_tags] def planned_snapshot_tags = planned_create_attributes[:snapshot_tags]
def planned_post_tags(locale) = planned_create_attributes(locale)[:post_tags] def planned_post_tags = planned_create_attributes[:post_tags]
def planned_sections(locale) = planned_create_attributes(locale)[:tag_sections] def planned_sections = planned_create_attributes[:tag_sections]
def planned_parent_post_ids locale def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids]
planned_create_attributes(locale)[:normalised_parent_post_ids]
def planned_video_ms
planned_create_attributes[:video_ms]
end end
def planned_video_ms locale def planned_create_attributes
planned_create_attributes(locale)[:video_ms]
end
def planned_create_attributes locale
@planned_create_attributes ||= begin @planned_create_attributes ||= begin
if @attributes.key?(:snapshot_tag_specs) if @attributes.key?(:snapshot_tag_specs)
snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || []) snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || [])
post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || []) post_tags = materialise_tags(@attributes[:post_tag_specs] || [])
{ {
snapshot_tags: snapshot_tags, snapshot_tags: snapshot_tags,
post_tags: post_tags, post_tags: post_tags,
tag_sections: materialise_sections( tag_sections: materialise_sections(
@attributes[:tag_sections] || { }, @attributes[:tag_sections] || { },
snapshot_tags, snapshot_tags,
post_tags, post_tags),
locale),
normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [], normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [],
video_ms: @attributes[:video_ms] } video_ms: @attributes[:video_ms] }
else else
build_materialised_plan(locale) build_materialised_plan
end end
end end
end end
def build_materialised_plan locale def build_materialised_plan
plan = PostCreatePlan.new(attributes: @attributes).build!(locale) plan = PostCreatePlan.new(attributes: @attributes).build!
snapshot_tags = materialise_tags(locale, plan[:snapshot_tag_specs] || []) snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || [])
post_tags = materialise_tags(locale, plan[:post_tag_specs] || []) post_tags = materialise_tags(plan[:post_tag_specs] || [])
{ {
snapshot_tags: snapshot_tags, snapshot_tags: snapshot_tags,
post_tags: post_tags, post_tags: post_tags,
tag_sections: materialise_sections( tag_sections: materialise_sections(
plan[:tag_sections] || { }, plan[:tag_sections] || { },
snapshot_tags, snapshot_tags,
post_tags, post_tags),
locale),
normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [], normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [],
video_ms: plan[:video_ms] } video_ms: plan[:video_ms] }
end end
def materialise_tags locale, specs def materialise_tags specs
Array(specs).each_with_object({ }) do |spec, tags| Array(specs).each_with_object({ }) do |spec, tags|
name = spec[:name] || spec['name'] name = spec[:name] || spec['name']
category = spec[:category] || spec['category'] category = spec[:category] || spec['category']
next if name.blank? || category.blank? next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(locale, name, category:) tag = Tag.find_or_create_by_tag_name!(name, category:)
tag.update!(category:) if tag.category.to_sym != category.to_sym tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag tags[name] ||= tag
end.values end.values
end end
def materialise_sections sections_by_name, snapshot_tags, post_tags, locale = Locale.nipponese def materialise_sections sections_by_name, snapshot_tags, post_tags
tags_by_name = post_tags.index_by { _1.name(locale.language_code) } tags_by_name = post_tags.index_by(&:name)
snapshot_tags.each do |tag| snapshot_tags.each do |tag|
tags_by_name[tag.name(locale.language_code)] ||= tag tags_by_name[tag.name] ||= tag
end end
sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections| sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections|
@@ -128,11 +121,9 @@ class PostCreator
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag| Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor) PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end end
PostTagSection.where(post_id: post.id).destroy_all PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges| sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms| ranges.each do |begin_ms, end_ms|
@@ -142,9 +133,10 @@ class PostCreator
end_ms:) end_ms:)
end end
end end
PostTag.where(post_id: post.id, PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).destroy_all tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
post_tag.discard_by!(@actor)
end
end end
def sync_parent_posts! post, ids def sync_parent_posts! post, ids
+16 -20
ファイルの表示
@@ -16,7 +16,7 @@ class PostImportPreviewer
THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze
METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze
def preview_rows rows:, locale: Locale.nipponese, fetch_metadata: true, metadata_cache: { } def preview_rows rows:, fetch_metadata: true, metadata_cache: { }
prepared_rows = rows.map { prepare_row(_1) } prepared_rows = rows.map { prepare_row(_1) }
url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally
existing_posts = existing_posts =
@@ -30,14 +30,13 @@ class PostImportPreviewer
url_counts) url_counts)
known_tags = known_tags =
preload_known_tags( preload_known_tags(
locale,
prepared_rows, prepared_rows,
fetch_metadata, fetch_metadata,
metadata_cache, metadata_cache,
existing_posts, existing_posts,
url_counts) url_counts)
prepared_rows.map { |row| prepared_rows.map { |row|
preview_row(locale, row, preview_row(row,
fetch_metadata:, fetch_metadata:,
metadata_cache:, metadata_cache:,
existing_posts:, existing_posts:,
@@ -60,7 +59,7 @@ class PostImportPreviewer
source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url)) source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url))
end end
def preview_row locale, row, def preview_row row,
fetch_metadata:, fetch_metadata:,
metadata_cache:, metadata_cache:,
existing_posts:, existing_posts:,
@@ -125,8 +124,7 @@ class PostImportPreviewer
attributes['url'] = url attributes['url'] = url
validate_basic_data(attributes, validation_errors) validate_basic_data(attributes, validation_errors)
validate_preview_tags(locale, validate_preview_tags(merged_tags(tag_sources, provenance['tags']),
merged_tags(tag_sources, provenance['tags']),
validation_errors, validation_errors,
known_tags) known_tags)
validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids) validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids)
@@ -348,7 +346,7 @@ class PostImportPreviewer
{ data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } }
end end
def preload_known_tags locale, prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts
names = prepared_rows.flat_map { |row| names = prepared_rows.flat_map { |row|
attributes = initial_attributes(row) attributes = initial_attributes(row)
provenance = initial_provenance(row) provenance = initial_provenance(row)
@@ -360,13 +358,15 @@ class PostImportPreviewer
metadata = metadata_for(row[:normal_url], metadata_cache) metadata = metadata_for(row[:normal_url], metadata_cache)
apply_metadata!(attributes, provenance, tag_sources, metadata[:data]) apply_metadata!(attributes, provenance, tag_sources, metadata[:data])
end end
preview_tag_names(locale, merged_tags(tag_sources, provenance['tags'])) preview_tag_names(merged_tags(tag_sources, provenance['tags']))
}.compact.uniq }.compact.uniq
return { } if names.empty? return { } if names.empty?
TagName.where(language_code: locale.language_code, primary_flg: true, name: names) Tag.joins(:tag_name)
.includes(:tag) .where(tag_names: { name: names })
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag } .includes(:tag_name)
.to_a
.index_by(&:name)
end end
def preload_parent_ids prepared_rows def preload_parent_ids prepared_rows
@@ -437,21 +437,19 @@ class PostImportPreviewer
sources['automatic'].to_s sources['automatic'].to_s
end end
def preview_tag_names locale, raw def preview_tag_names raw
names = raw.to_s.split names = raw.to_s.split
return [] if names.empty? return [] if names.empty?
if names.any? { _1.downcase.start_with?('nico:') } if names.any? { _1.downcase.start_with?('nico:') }
return [] return []
end end
names.map { |name| names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
}
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
[] []
end end
def validate_preview_tags locale, raw, errors, known_tags def validate_preview_tags raw, errors, known_tags
names = raw.to_s.split names = raw.to_s.split
return if names.empty? return if names.empty?
if names.any? { _1.downcase.start_with?('nico:') } if names.any? { _1.downcase.start_with?('nico:') }
@@ -459,11 +457,9 @@ class PostImportPreviewer
return return
end end
parsed = names.map { |name| parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first }
TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first
}
existing = parsed.filter_map { known_tags[_1] } existing = parsed.filter_map { known_tags[_1] }
deprecated = existing.select(&:deprecated?).map { _1.name(locale.language_code) } deprecated = existing.select(&:deprecated?).map(&:name)
errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present?
rescue Tag::SectionLiteralParseError rescue Tag::SectionLiteralParseError
errors[:tags] = ['タグ区間の記法が不正です.'] errors[:tags] = ['タグ区間の記法が不正です.']
+3 -3
ファイルの表示
@@ -47,9 +47,9 @@ class PostMetadataFetcher
existing_tags = existing_tags =
Tag Tag
.joins(:tag_names) .joins(:tag_name)
.where(tag_names: { language_code: 'ja', primary_flg: true, name: names }) .where(tag_names: { name: names })
.index_by { _1.name('ja') } .index_by(&:name)
names.map { |name| names.map { |name|
tag = existing_tags[name] tag = existing_tags[name]
-1
ファイルの表示
@@ -25,7 +25,6 @@ class PostVersionRecorder < VersionRecorder
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms, video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
tags_json: @record.snapshot_tags_json,
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
original_created_before: @record.original_created_before } original_created_before: @record.original_created_before }
+3 -18
ファイルの表示
@@ -1,5 +1,5 @@
class TagVersionRecorder < VersionRecorder class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:, **_options def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record! new(tag:, event_type:, created_by_user:).record!
end end
@@ -14,25 +14,10 @@ class TagVersionRecorder < VersionRecorder
def record_key = :tag def record_key = :tag
def snapshot_attributes def snapshot_attributes
names = snapshot_names { name: @record.name,
japanese_names = names.select { _1['language_code'] == 'ja' }
compatibility_names = japanese_names.presence || names
{ name: compatibility_names.find { _1['primary_flg'] }['name'],
category: @record.category, category: @record.category,
deprecated_at: @record.deprecated_at, deprecated_at: @record.deprecated_at,
aliases: compatibility_names.reject { _1['primary_flg'] }.map { _1['name'] }.join(' '), aliases: @record.snapshot_aliases.join(' '),
names:,
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end end
def snapshot_names
@record.tag_names.order(:language_code, primary_flg: :desc).order(:name).map { |tag_name|
{ 'language_code' => tag_name.language_code,
'name' => tag_name.name,
'script_code' => tag_name.script_code,
'primary_flg' => tag_name.primary_flg,
'auto_generated' => tag_name.auto_generated }
}
end
end end
+26 -10
ファイルの表示
@@ -1,22 +1,38 @@
class TagVersioning class TagVersioning
def self.record! tag, event_type:, created_by_user:, language_code: 'ja' def self.record! tag, event_type:, created_by_user:
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end end
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja' def self.ensure_snapshot! tag, created_by_user:
return if tag.tag_versions.exists? if tag.nico?
return if tag.nico_tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:) 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
end end
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja' def self.record_tag_snapshot! tag, created_by_user:
event_type = tag.tag_versions.exists? ? :update : :create event_type =
record!(tag, event_type:, created_by_user:, language_code:) 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:)
end end
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja' def self.record_tag_snapshots! tags, created_by_user:
tags.each do |tag| tags.each do |tag|
record_tag_snapshot!(tag, created_by_user:, language_code:) record_tag_snapshot!(tag, created_by_user:)
end end
end end
end end
+3 -3
ファイルの表示
@@ -41,7 +41,7 @@ class TheatrePostSelector
def weighted_candidates def weighted_candidates
@weighted_candidates ||= begin @weighted_candidates ||= begin
penalties = tag_penalties penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_names).to_a posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post| posts.map do |post|
post_tags = post.tags.to_a post_tags = post.tags.to_a
@@ -82,7 +82,7 @@ class TheatrePostSelector
def tag_penalty_json def tag_penalty_json
return [] if tag_penalties.empty? return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_names).index_by(&:id) tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties tag_penalties
.map { |tag_id, penalty| .map { |tag_id, penalty|
@@ -113,7 +113,7 @@ class TheatrePostSelector
def light_tag_json tag def light_tag_json tag
{ id: tag.id, { id: tag.id,
name: tag.name('ja'), name: tag.name,
category: tag.category } category: tag.category }
end end
end end
+1 -5
ファイルの表示
@@ -47,14 +47,10 @@ class VersionRecorder
end end
def update_record_version_no! version_no def update_record_version_no! version_no
return unless tracks_version_no_on_record?
@record.update_columns(version_no:) @record.update_columns(version_no:)
@record.version_no = version_no @record.version_no = version_no
end end
def tracks_version_no_on_record? = true
def validate_version_sequence! latest def validate_version_sequence! latest
if !(latest) && @event_type != 'create' if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create" raise "#{ version_class.name } first event must be create"
@@ -64,7 +60,7 @@ class VersionRecorder
raise "#{ version_class.name } create event already exists" raise "#{ version_class.name } create event already exists"
end end
return if !(latest) || !(tracks_version_no_on_record?) return unless latest
if @record.version_no != latest.version_no if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.destroy! pt.discard_by!(nil)
end end
end end
+2 -1
ファイルの表示
@@ -1,5 +1,5 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :update]
scope 'tags/:parent_id/children', controller: :tag_children do scope 'tags/:parent_id/children', controller: :tag_children do
post ':child_id', action: :create post ':child_id', action: :create
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
get :metadata get :metadata
post :bulk post :bulk
get :random get :random
get :changes
get :versions, to: 'post_versions#index' get :versions, to: 'post_versions#index'
end end
-299
ファイルの表示
@@ -1,299 +0,0 @@
class AddTagsJsonToPostVersions < ActiveRecord::Migration[8.0]
RESOLUTION_GRACE = 1.second
SECTION_LITERAL_PATTERN = /\[[^\[\]\s]*-[^\[\]\s]*\]\z/
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
class MigrationTag < ActiveRecord::Base
self.table_name = 'tags'
end
class MigrationTagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
add_column :post_versions, :tags_json, :json, after: :tags
MigrationPostVersion.reset_column_information
backfill_missing_initial_tag_versions!
intervals_by_name = build_intervals_by_name
say_with_time 'Backfilling post_versions.tags_json' do
MigrationPostVersion.where(tags_json: nil).find_each(batch_size: 500) do |version|
version.update_columns(tags_json: build_tags_json(version, intervals_by_name))
end
end
change_column_null :post_versions, :tags_json, false
schema = connection.quote(JSON.generate({
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 } }))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def down
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
remove_column :post_versions, :tags_json
end
private
def backfill_missing_initial_tag_versions!
say_with_time 'Backfilling missing initial tag versions' do
tag_rows = missing_initial_version_rows(MigrationTagVersion, nico: false)
nico_rows = missing_initial_version_rows(MigrationNicoTagVersion, nico: true)
MigrationTagVersion.insert_all!(tag_rows) if tag_rows.any?
MigrationNicoTagVersion.insert_all!(nico_rows) if nico_rows.any?
tag_rows.length + nico_rows.length
end
end
def missing_initial_version_rows version_class, nico:
first_versions =
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
.transform_values(&:first)
rows = []
MigrationTag.find_each do |tag|
next if (tag.category == 'nico') != nico
first_version = first_versions[tag.id]
next unless first_version
next if valid_initial_version?(first_version)
assert_inferable_initial_version!(tag, first_version)
rows << initial_version_row(tag, first_version, nico:)
end
rows
end
def valid_initial_version? version
version.version_no == 1 && version.event_type == 'create'
end
def assert_inferable_initial_version! tag, version
inferable =
version.version_no == 2 &&
version.event_type == 'discard' &&
tag.created_at < version.created_at
return if inferable
details = [
"tag_id=#{ tag.id }",
"version_no=#{ version.version_no }",
"event_type=#{ version.event_type.inspect }",
"tag_created_at=#{ tag.created_at.iso8601(6) }",
"version_created_at=#{ version.created_at.iso8601(6) }"]
raise "Cannot infer initial tag version: #{ details.join(', ') }"
end
def initial_version_row tag, discard_version, nico:
row = {
tag_id: tag.id,
version_no: 1,
event_type: 'create',
name: discard_version.name,
created_at: tag.created_at,
created_by_user_id: nil }
if nico
return row.merge(linked_tags: discard_version.linked_tags)
end
row.merge(
category: discard_version.category,
aliases: discard_version.aliases,
parent_tag_ids: discard_version.parent_tag_ids,
deprecated_at: discard_version.deprecated_at)
end
def build_intervals_by_name
intervals_by_name = Hash.new { |hash, name| hash[name] = [] }
versions_by_kind = {
tag: versions_by_tag_id(MigrationTagVersion),
nico: versions_by_tag_id(MigrationNicoTagVersion) }
current_names = current_names_by_tag_id
MigrationTag.find_each do |tag|
nico = tag.category == 'nico'
kind = nico ? :nico : :tag
versions = versions_by_kind.fetch(kind).fetch(tag.id, [])
intervals_for(
tag,
versions,
current_name: current_names.fetch(tag.id),
nico:).each do |interval|
name = interval.delete(:name)
intervals_by_name[name] << interval
end
end
intervals_by_name
end
def versions_by_tag_id version_class
version_class
.order(:tag_id, :version_no)
.to_a
.group_by(&:tag_id)
end
def current_names_by_tag_id
MigrationTagName
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
.pluck('tags.id', 'tag_names.name')
.to_h
end
def intervals_for tag, versions, current_name:, nico:
if versions.empty?
return [{
name: current_name,
tag_id: tag.id,
version_no: tag.version_no,
category: nico ? 'nico' : tag.category,
from: tag.created_at,
to: tag.discarded_at }]
end
versions.each_with_index.filter_map do |version, index|
next if version.event_type == 'discard'
{
name: version.name,
tag_id: tag.id,
version_no: version.version_no,
category: nico ? 'nico' : version.category,
from: version.created_at,
to: versions[index + 1]&.created_at || tag.discarded_at }
end
end
def build_tags_json version, intervals_by_name
entries = version.tags.to_s.split.map do |literal|
name = tag_name_from_literal(literal)
interval = resolve_tag!(intervals_by_name.fetch(name, []), name:, version:)
{ 'id' => interval.fetch(:tag_id),
'version_no' => interval.fetch(:version_no),
'name' => name,
'category' => interval.fetch(:category),
'sections' => [] }
end
assert_unique_tag_ids!(version, entries)
entries.sort_by { |entry| entry.fetch('id') }
end
def tag_name_from_literal literal
name = literal.dup
name.sub!(SECTION_LITERAL_PATTERN, '') while name.match?(
SECTION_LITERAL_PATTERN)
if name.empty? || name.include?('[') || name.include?(']')
raise "Invalid legacy tag literal: #{ literal.inspect }"
end
name
end
def resolve_tag! intervals, name:, version:
time = version.created_at
candidates = intervals.select do |interval|
interval.fetch(:from) <= time &&
(interval[:to].nil? || time < interval.fetch(:to))
end
candidates = future_candidates(intervals, time) if candidates.empty?
return candidates.first if candidates.one?
candidate_versions = candidates.map do |candidate|
[candidate.fetch(:tag_id), candidate.fetch(:version_no)]
end
details = [
"post_version_id=#{ version.id }",
"post_id=#{ version.post_id }",
"name=#{ name.inspect }",
"created_at=#{ time.iso8601(6) }",
"candidates=#{ candidate_versions.inspect }"].join(', ')
raise "Could not resolve tag snapshot: #{ details }"
end
def future_candidates intervals, time
candidates = intervals.select do |interval|
interval.fetch(:from) > time &&
interval.fetch(:from) <= time + RESOLUTION_GRACE
end
return [] if candidates.empty?
nearest_from = candidates.map { |interval| interval.fetch(:from) }.min
candidates.select do |interval|
interval.fetch(:from) == nearest_from
end
end
def assert_unique_tag_ids! version, entries
duplicate_tag_ids =
entries
.map { |entry| entry.fetch('id') }
.tally
.select { |_tag_id, count| count > 1 }
.keys
return if duplicate_tag_ids.empty?
details = [
"post_version_id=#{ version.id }",
"duplicate_tag_ids=#{ duplicate_tag_ids.inspect }"].join(', ')
raise "Duplicate tag IDs: #{ details }"
end
end
-53
ファイルの表示
@@ -1,53 +0,0 @@
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
@@ -1,11 +0,0 @@
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
ファイルの表示
@@ -1,46 +0,0 @@
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
ファイルの表示
@@ -1,18 +0,0 @@
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
ファイルの表示
@@ -1,54 +0,0 @@
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
ファイルの表示
@@ -1,29 +0,0 @@
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
ファイルの表示
@@ -1,6 +0,0 @@
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
ファイルの表示
@@ -1,199 +0,0 @@
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
ファイルの表示
@@ -1,20 +0,0 @@
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
-20
ファイルの表示
@@ -1,20 +0,0 @@
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
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
ファイルの表示
@@ -1,25 +0,0 @@
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.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
end
execute <<~SQL
INSERT INTO
locales(code, language_code, script_code, name, created_at)
VALUES
('ja', 'ja', 'Jpan', '日本語', #{ connection.quote Time.current })
SQL
end
def down
drop_table :locales
end
end
-59
ファイルの表示
@@ -1,59 +0,0 @@
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_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
end
def down
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, :primary_flg
remove_column :tag_names, :script_code
remove_column :tag_names, :language_code
remove_reference :tag_names, :tag, foreign_key: true
end
end
-38
ファイルの表示
@@ -1,38 +0,0 @@
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|
names = [{
language_code: 'ja',
name: version.name,
script_code: 'Jpan',
primary_flg: true,
auto_generated: false }]
names.concat(version.aliases.to_s.split.filter_map { |name|
next if name.blank?
{
language_code: 'ja',
name:,
script_code: 'Jpan',
primary_flg: false,
auto_generated: false }
})
version.update_columns(names: names)
end
end
change_column_null :tag_versions, :names, false
end
def down
remove_column :tag_versions, :names
end
end
-19
ファイルの表示
@@ -1,19 +0,0 @@
class AddAutoGeneratedToTagNames < ActiveRecord::Migration[8.0]
class TagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
def up
add_column :tag_names, :auto_generated, :boolean, null: false, default: false
TagVersion.reset_column_information
TagVersion.find_each do |version|
names = Array(version.names).map { |name| name.merge('auto_generated' => false) }
version.update_columns(names: names)
end
end
def down
remove_column :tag_names, :auto_generated
end
end
生成ファイル
+38 -77
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -48,14 +48,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id" t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end 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| create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false t.string "model", null: false
t.integer "input_tokens", default: 0, null: false t.integer "input_tokens", default: 0, null: false
@@ -138,24 +130,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end 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.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"], name: "index_locales_on_language_code"
t.index ["script_code"], name: "fk_rails_0b74ce96a8"
end
create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false t.bigint "material_id", null: false
t.string "profile", default: "legacy_drive", null: false t.string "profile", default: "legacy_drive", null: false
@@ -307,13 +281,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end 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| 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 "post_id", null: false
t.bigint "parent_post_id", null: false t.bigint "parent_post_id", null: false
@@ -344,12 +311,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end end
create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "created_user_id" t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false 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 ["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" t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end end
@@ -371,7 +349,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
t.string "thumbnail_base", limit: 2000 t.string "thumbnail_base", limit: 2000
t.text "tags", null: false t.text "tags", null: false
t.json "tags_json", null: false
t.text "parent_post_ids", null: false t.text "parent_post_ids", null: false
t.datetime "original_created_from" t.datetime "original_created_from"
t.datetime "original_created_before" t.datetime "original_created_before"
@@ -385,7 +362,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" 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 "`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 "`version_no` > 0", name: "post_versions_version_no_positive"
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 end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -406,13 +382,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end 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"
end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -424,6 +393,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end 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
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
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -445,21 +427,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
end end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| 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 "name", null: false
t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false
t.bigint "canonical_id" t.bigint "canonical_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" t.datetime "discarded_at"
t.boolean "auto_generated", default: false, null: false
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true t.index ["name"], name: "index_tag_names_on_name", unique: true
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
t.index ["tag_id"], name: "index_tag_names_on_tag_id"
end end
create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -481,7 +456,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.datetime "deprecated_at" t.datetime "deprecated_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" 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_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 ["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", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
@@ -496,8 +470,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" 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.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 "(`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" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -638,19 +614,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end 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
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
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -736,8 +699,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
add_foreign_key "gekanator_questions", "users", column: "created_by_id" 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", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "material_export_items", "materials" add_foreign_key "material_export_items", "materials"
add_foreign_key "material_export_items", "users", column: "created_by_user_id" add_foreign_key "material_export_items", "users", column: "created_by_user_id"
add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" add_foreign_key "material_import_blocks", "users", column: "created_by_user_id"
@@ -746,37 +707,37 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id" add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials" add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id" 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: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_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", "materials", column: "parent_id"
add_foreign_key "materials", "tags" add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_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"
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 "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"
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
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_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" 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", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id" 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", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id" 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 "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
+33 -58
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids to_add = desired_tag_ids - current_tag_ids
@@ -30,28 +30,9 @@ namespace :nico do
end end
end end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!) PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
end pt.discard_by!(nil)
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 end
PostExternalTag
.where(post_id: post.id, external_tag_id: to_remove.to_a)
.find_each(&:destroy!)
end end
mysql_user = ENV['MYSQL_USER'] mysql_user = ENV['MYSQL_USER']
@@ -130,64 +111,58 @@ namespace :nico do
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end end
tags = post.tags
# 既存のタグ Id. 集合 # 既存のタグ Id. 集合
kept_tag_ids = post.tags.pluck(:id).to_set kept_tag_ids = tags.pluck(:id).to_set
# うち内部タグ Id. 集合
# 既存の外部タグ Id. 集合 kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
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. のリスト # 記載すべき内部タグ Id. のリスト
desired_tag_ids = kept_tag_ids.to_a desired_non_nico_tag_ids = []
datum['tags'].each do |raw| datum['tags'].each do |raw|
name = TagNameSanitisationRule.sanitise("nico:#{ raw }").delete_prefix('nico:') name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = ExternalTag.find_or_create_by!(platform: :nico, name:) tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
unless tag.nico_tag_versions.exists? event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(external_tag: tag, NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
event_type: :create,
created_by_user: nil)
end
desired_external_tag_ids << tag.id desired_nico_tag_based_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載 # 新たに記載される外部タグと連携される内部タグを記載
# 連携タグは記載すれども消除せず. unless tag.id.in?(kept_tag_ids)
unless tag.id.in?(kept_external_tag_ids) linked_ids = tag.linked_tags.pluck(:id)
desired_tag_ids.concat(tag.linked_tags.pluck(:id)) desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_tag_based_ids.concat(linked_ids)
end end
end end
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user']) deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist if deerjikist
desired_tag_ids << deerjikist.tag_id desired_non_nico_tag_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?) desired_nico_tag_based_ids << deerjikist.tag_id
desired_tag_ids << Tag.no_deerjikist.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
end end
desired_external_tag_ids.uniq! desired_nico_tag_based_ids.uniq!
desired_tag_ids.uniq!
# 外部タグの記載に際しては “bot 操作” タグを記載しなぃ. desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids
if kept_tag_ids != desired_tag_ids.to_set desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a)
desired_tag_ids << Tag.bot.id desired_non_nico_tag_ids.uniq!
desired_tag_ids.uniq! if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set
desired_all_tag_ids << Tag.bot.id
end end
desired_all_tag_ids.uniq!
tags_changed = sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
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 if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || tags_changed elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end end
-124
ファイルの表示
@@ -1,124 +0,0 @@
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
ファイルの表示
@@ -1,191 +0,0 @@
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
-6
ファイルの表示
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
+1 -9
ファイルの表示
@@ -1,13 +1,5 @@
FactoryBot.define do FactoryBot.define do
factory :tag_name do factory :tag_name do
language_code { 'ja' } name { "tag-#{SecureRandom.hex(4)}" }
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
end end
+8 -15
ファイルの表示
@@ -1,29 +1,22 @@
FactoryBot.define do FactoryBot.define do
factory :tag do factory :tag do
transient do transient do
primary_name { nil } name { nil }
primary_tag_name do
attributes = primary_name.nil? ? { } : { name: primary_name }
build(:tag_name, **attributes)
end
end end
category { :general } category { :general }
post_count { 0 } post_count { 0 }
association :tag_name
after(:build) do |tag, evaluator| after(:build) do |tag, evaluator|
evaluator.primary_tag_name.primary_flg = true tag.name = evaluator.name if evaluator.name.present?
tag.tag_names.target << evaluator.primary_tag_name
end end
before(:create) do |tag, evaluator| trait :nico do
evaluator.primary_tag_name.save! category { :nico }
# Compatibility write only; ownership is established through tag_id below. transient do
tag.tag_name_id = evaluator.primary_tag_name.id name { "nico:#{ SecureRandom.hex(4) }" }
end end
after(:create) do |tag, evaluator|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
end end
end end
end end
-38
ファイルの表示
@@ -1,38 +0,0 @@
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
-71
ファイルの表示
@@ -1,71 +0,0 @@
require 'rails_helper'
RSpec.describe Locale, type: :model do
def prepare_french_reference!
Language.find_or_create_by!(code: 'fr') { _1.name = 'French' }
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
end
def create_french_locale!
prepare_french_reference!
described_class.create!(code: 'fr', language_code: 'fr',
script_code: 'Latn', name: 'French')
end
it 'generates a primary name for every existing tag using the name generator' do
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').name)
.and_return('name_from_generator')
locale = create_french_locale!
expect(TagName).to have_received(:generate_name)
.with(locale, first, primary_tag_name_for(first, 'ja').name)
expect(TagName).to have_received(:generate_name)
.with(locale, second, primary_tag_name_for(second, 'ja').name)
expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'fr', script_code: 'Latn',
primary_flg: true)
expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'fr',
script_code: 'Latn', primary_flg: true)
expect(TagName.where(language_code: 'fr', 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
prepare_french_reference!
tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'nom_existant', tag:,
language_code: 'fr', script_code: 'Latn')
create_french_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true))
.to contain_exactly(existing)
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: true)
end
it 'creates a primary name when the language has only an alias' do
prepare_french_reference!
tag = create(:tag, primary_name: 'alias_only_tag')
alias_name = create(:tag_name, :alias, name: 'alias_fr',
tag:,
language_code: 'fr', script_code: 'Latn')
create_french_locale!
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
language_code: 'fr', script_code: 'Latn', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr',
script_code: 'Latn', primary_flg: true)
expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true).count).to eq(1)
end
end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe MaterialExportItem, type: :model do RSpec.describe MaterialExportItem, type: :model do
let(:user) { create(:user, :member) } let(:user) { create(:user, :member) }
let(:tag) { create(:tag, primary_name: 'export_item', category: :material) } let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) }
let(:material) do let(:material) do
Material.create!(tag:, url: 'https://example.com/material', Material.create!(tag:, url: 'https://example.com/material',
created_by_user: user, updated_by_user: user) created_by_user: user, updated_by_user: user)
-27
ファイルの表示
@@ -1,27 +0,0 @@
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
ファイルの表示
@@ -1,25 +0,0 @@
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,25 +6,6 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all PostUrlSanitisationRule.unscoped.delete_all
end 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 describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!( PostUrlSanitisationRule.create!(
+4 -79
ファイルの表示
@@ -1,73 +1,5 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do 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 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 it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag) post_tag = create(:post_tag)
section = create(:post_tag_section, section = create(:post_tag_section,
@@ -80,25 +12,18 @@ RSpec.describe PostTag, type: :model do
end end
it 'does not load sections for another tag on the same post' do it 'does not load sections for another tag on the same post' do
post_tag = create(:post_tag) post = create(:post)
post = post_tag.post tag = create(:tag)
other_tag = create(:tag) other_tag = create(:tag)
own_section = create(:post_tag_section, post_tag = create(:post_tag, post:, tag:)
post:,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
create(:post_tag, post:, tag: other_tag)
create(:post_tag_section, create(:post_tag_section,
post:, post:,
tag: other_tag, tag: other_tag,
begin_ms: 1000, begin_ms: 1000,
end_ms: 2000) end_ms: 2000)
expect(post_tag.reload.sections).to contain_exactly(own_section) expect(post_tag.sections).to be_empty
end end
it 'allows open-ended sections' do it 'allows open-ended sections' do
+2 -3
ファイルの表示
@@ -1,8 +1,8 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe PostVersion, type: :model do RSpec.describe PostVersion, type: :model do
let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') } let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') }
let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) } let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
let!(:post_record) do let!(:post_record) do
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post|
@@ -19,7 +19,6 @@ RSpec.describe PostVersion, type: :model do
url: post_record.url, url: post_record.url,
thumbnail_base: post_record.thumbnail_base, thumbnail_base: post_record.thumbnail_base,
tags: post_record.snapshot_tag_names.join(' '), tags: post_record.snapshot_tag_names.join(' '),
tags_json: post_record.snapshot_tags_json,
parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), parent_post_ids: post_record.snapshot_parent_post_ids.join(' '),
original_created_from: post_record.original_created_from, original_created_from: post_record.original_created_from,
original_created_before: post_record.original_created_before, original_created_before: post_record.original_created_before,
+5 -5
ファイルの表示
@@ -2,8 +2,8 @@ require 'rails_helper'
RSpec.describe TagImplication, type: :model do RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do it 'rejects a parent tag that would create a cycle' do
child = create(:tag, primary_name: 'tag_implication_cycle_child') child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, primary_name: 'tag_implication_cycle_parent') parent = create(:tag, name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent) described_class.create!(tag: child, parent_tag: parent)
@@ -17,9 +17,9 @@ RSpec.describe TagImplication, type: :model do
end end
it 'terminates even when existing data already contains a cycle' do it 'terminates even when existing data already contains a cycle' do
child = create(:tag, primary_name: 'tag_implication_existing_cycle_child') child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, primary_name: 'tag_implication_existing_cycle_parent') parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, primary_name: 'tag_implication_existing_cycle_ancestor') ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor) described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!( described_class.insert_all!(
+20 -82
ファイルの表示
@@ -34,41 +34,9 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.create!(priority: 10, source_pattern: '_', replacement: '') described_class.create!(priority: 10, source_pattern: '_', replacement: '')
end 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
japanese = 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(japanese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end
it 'renames without merging tags or deleting either language identity' do
japanese = 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(japanese.reload, 'ja')).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: japanese.id)
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
end
end
context 'when no conflicting tag_name exists' do context 'when no conflicting tag_name exists' do
let!(:tag_name) do let!(:tag_name) do
create(:tag_name, name: 'tmp').tap do |tn| TagName.create!(name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current) tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end end
end end
@@ -79,72 +47,44 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
end end
context 'when a conflicting primary tag name exists' do context 'when a conflicting canonical tag_name exists' do
let!(:existing) { create(:tag_name, name: 'foobar') } let!(:existing) { TagName.create!(name: 'foobar') }
let!(:source) do let!(:source) do
create(:tag_name, name: 'tmp').tap do |tn| TagName.create!(name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current) tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end end
end end
it 'deletes the source tag_name' do it 'deletes the source tag_name' do
described_class.apply! described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false) expect(TagName.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar') expect(existing.reload.name).to eq('foobar')
end end
end end
context 'when the source tag_name has a tag and the existing one has no tag' do context 'when the source tag_name has a tag and the existing one has no tag' do
let!(:existing) { create(:tag_name, name: 'foobar') } let!(:existing) { TagName.create!(name: 'foobar') }
let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) } let!(:source_tag) { create(:tag, name: 'tmp', category: :general) }
let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id } let!(:source_tag_name_id) { source_tag.tag_name_id }
before do before do
primary_tag_name_for(source_tag, 'ja').update_columns( source_tag.tag_name.update_columns(name: 'foo_bar', updated_at: Time.current)
name: 'foo_bar', updated_at: Time.current)
end end
it 'moves the tag to the existing tag_name' do it 'moves the tag to the existing tag_name' do
described_class.apply! described_class.apply!
expect(existing.reload).to have_attributes( expected_tag_name_id = existing.canonical_id || existing.id
tag_id: source_tag.id, language_code: 'ja', primary_flg: true) expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing) expect(TagName.exists?(source_tag_name_id)).to be(false)
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
end end
context 'when both source and existing tag_names have tags' do context 'when both source and existing tag_names have tags' do
let!(:existing_tn) { create(:tag_name, name: 'foobar') } let!(:existing_tn) { TagName.create!(name: 'foobar') }
let!(:existing_tag) { create(:tag, primary_tag_name: existing_tn, category: :general) } let!(:existing_tag) { Tag.create!(tag_name: existing_tn, category: :general) }
let!(:source_tn) { create(:tag_name, name: 'tmp') } let!(:source_tn) { TagName.create!(name: 'tmp') }
let!(:source_tag) { create(:tag, primary_tag_name: source_tn, category: :general) } let!(:source_tag) { Tag.create!(tag_name: source_tn, category: :general) }
let!(:source_tag_name_id) { source_tn.id } let!(:source_tag_name_id) { source_tn.id }
before do before do
@@ -152,15 +92,13 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end end
it 'merges the source tag into the existing tag and deletes the source tag_name' do it 'merges the source tag into the existing tag and deletes the source tag_name' do
post = create(:post) expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id)
PostTag.create!(post:, tag: source_tag) expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id)
described_class.apply! described_class.apply!
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) expect(TagName.exists?(source_tag.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 end
end end
-127
ファイルの表示
@@ -1,127 +0,0 @@
require 'rails_helper'
RSpec.describe TagName, type: :model do
include_context 'English locale'
it 'allows the same name in different languages' do
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 Japanese 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, tag_id: nil)
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 '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 'leaves an alias unchanged when its language has no primary' do
tag = create(:tag, primary_name: '日本語のみ')
create(:tag_name, :alias, tag:, name: 'orphan_english_alias',
language_code: 'en', script_code: 'Latn')
expect(described_class.canonicalise(locale, ['orphan_english_alias']))
.to eq(['orphan_english_alias'])
end
it 'resolves the primary name of the alias language on the same tag' do
tag = create(:tag, primary_name: '日本語正本')
english_primary = create(:tag_name, tag:, name: 'english_primary',
language_code: 'en', script_code: 'Latn')
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
japanese = create(:tag, primary_name: 'japanese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.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(['japanese_canonical'])
end
end
end
+141 -392
ファイルの表示
@@ -1,184 +1,129 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do 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 describe '.normalise_tags!' do
it 'canonicalises aliases in the supplied language only' do
Language.find_or_create_by!(code: 'en') { _1.name = 'English' }
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
Locale.insert_all!([
{ code: 'en', language_code: 'en', script_code: 'Latn',
name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en')
english_locale = Locale.find('en')
japanese = create(:tag, primary_name: 'japanese_canonical')
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.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([japanese])
end
it 'rejects deprecated tags when deny_deprecated is enabled' do it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = create(:tag_name, name: 'normalise deprecated tag') tag_name = TagName.create!(name: 'normalise deprecated tag')
deprecated_tag = create(:tag, deprecated_tag = Tag.create!(
primary_tag_name: tag_name, tag_name:,
category: :general, category: :general,
deprecated_at: 1.day.from_now) deprecated_at: 1.day.from_now
)
expect { expect {
described_class.normalise_tags!(Locale.nipponese, described_class.normalise_tags!(
[primary_tag_name_for(deprecated_tag, 'ja').name], [deprecated_tag.name],
deny_deprecated: true) deny_deprecated: true
)
}.to raise_error(Tag::DeprecatedTagNormalisationError) { |error| }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error|
expect(error.tag_names).to eq([primary_tag_name_for(deprecated_tag, 'ja').name]) expect(error.tag_names).to eq([deprecated_tag.name])
} }
end end
it 'rejects invalid section literals instead of treating them as zero' do it 'rejects invalid section literals instead of treating them as zero' do
expect { expect {
described_class.normalise_tags!(Locale.nipponese, described_class.normalise_tags!(
['normalise_invalid_section[1:aa-2:00]'], ['normalise_invalid_section[1:aa-2:00]'],
with_sections: true) with_sections: true
)
}.to raise_error(Tag::SectionLiteralParseError) }.to raise_error(Tag::SectionLiteralParseError)
end end
it 'parses open-ended section literals' do it 'parses open-ended section literals' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-]'], ['伊地知ニジカ[1:00-]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end end
it 'parses omitted begin as zero' do it 'parses omitted begin as zero' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[-1:00]'], ['伊地知ニジカ[-1:00]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end end
it 'treats fully open section literals as plain tags' do it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[-]'], ['伊地知ニジカ[-]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections)[tag.id]).to be_nil expect(result.fetch(:sections)[tag.id]).to be_nil
end end
it 'treats [0:00-] as a plain tag' do it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[0:00-]'], ['伊地知ニジカ[0:00-]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections)[tag.id]).to be_nil expect(result.fetch(:sections)[tag.id]).to be_nil
end end
it 'expands zero-width sections to one millisecond' do it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-1:00]'], ['伊地知ニジカ[1:00-1:00]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end end
it 'swaps reversed section boundaries' do it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[2:00-1:00]'], ['伊地知ニジカ[2:00-1:00]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end end
it 'merges open-ended sections over later bounded sections' do it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-][2:00-3:00]'], ['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end end
it 'merges adjacent bounded and open-ended sections' do it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!(Locale.nipponese, result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-3:00][3:00-]'], ['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true) with_sections: true
)
tag = result.fetch(:tags).find do |candidate| tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ'
end
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end end
end end
describe '.expand_parent_tags' do describe '.expand_parent_tags' do
it 'expands through multiple deprecated parents to an active ancestor' do it 'expands through multiple deprecated parents to an active ancestor' do
child = create(:tag, primary_name: 'expand_child') child = create(:tag, name: 'expand_child')
deprecated_parent = create(:tag, deprecated_parent = create(
primary_name: 'expand_deprecated_parent', :tag,
deprecated_at: Time.current) name: 'expand_deprecated_parent',
deprecated_grandparent = create(:tag, deprecated_at: Time.current
primary_name: 'expand_deprecated_grandparent', )
deprecated_at: Time.current) deprecated_grandparent = create(
active_ancestor = create(:tag, primary_name: 'expand_active_ancestor') :tag,
name: 'expand_deprecated_grandparent',
deprecated_at: Time.current
)
active_ancestor = create(:tag, name: 'expand_active_ancestor')
TagImplication.create!(tag: child, parent_tag: deprecated_parent) TagImplication.create!(tag: child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor)
@@ -195,8 +140,8 @@ RSpec.describe Tag, type: :model do
end end
it 'terminates when implications contain a cycle' do it 'terminates when implications contain a cycle' do
first = create(:tag, primary_name: 'expand_cycle_first') first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, primary_name: 'expand_cycle_second') second = create(:tag, name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second) TagImplication.create!(tag: first, parent_tag: second)
now = Time.current now = Time.current
TagImplication.insert_all!( TagImplication.insert_all!(
@@ -214,170 +159,45 @@ RSpec.describe Tag, type: :model do
end end
end end
describe '.find_or_create_by_tag_name!' do describe 'deprecated validation' do
context 'with an explicit locale' do it 'rejects deprecated nico tags' do
include_context 'English locale' tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
it 'creates the representative name with locale attributes and ownership' do expect(tag).not_to be_valid
tag = described_class.find_or_create_by_tag_name!( expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
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 'creates separate tag identities for the same name in different languages' do
japanese = 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(japanese)
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(japanese, 'ja')).to have_attributes(
name: 'same_name', tag_id: japanese.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(japanese)
end
it 'preserves the V1 representative when looking up another primary language' do
tag = create(:tag, primary_name: '日本語代表名')
representative_id = tag.tag_name_id
english = create(:tag_name, name: 'english_primary', tag:,
language_code: 'en', script_code: 'Latn')
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
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')
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
end end
describe '.merge_tags!' do describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) } let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) }
let!(:source_tag_name) { primary_tag_name_for(source_tag, 'ja') } let!(:source_tag_name) { source_tag.tag_name }
let!(:post_record) do let!(:post_record) do
Post.create!(url: 'https://example.com/posts/1', title: 'test post') Post.create!(url: 'https://example.com/posts/1', title: 'test post')
end end
context 'with names in multiple languages' do
include_context 'English locale'
it 'keeps the target primary and moves source names as aliases' do
english_primary = create(:tag_name, tag: target_tag,
name: 'target_english', language_code: 'en', script_code: 'Latn')
source_primary = create(:tag_name, tag: source_tag,
name: 'source_english', language_code: 'en', script_code: 'Latn')
source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
end
it 'inherits a source primary when the target has no name in that language' do
source_primary = create(:tag_name, tag: source_tag,
name: 'inherited_english', language_code: 'en', script_code: 'Latn')
source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'inherited_alias', language_code: 'en', script_code: 'Latn')
described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(source_primary)
expect(source_primary.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'en', primary_flg: true)
expect(source_alias.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'en', primary_flg: false)
end
end
context 'when merging a simple source tag' do context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'deletes the source tag, moves its post link, and keeps its name as an alias' do it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
target_link = PostTag.find_by(post: post_record, 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)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(source_pt.discarded_at).to be_present
expect(target_link).to be_present expect(source_pt.tag_id).to eq(source_tag.id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(active_target).to be_present
expect(source_tag_name.reload).to have_attributes(
tag_id: target_tag.id, language_code: 'ja', primary_flg: false) 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(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -386,111 +206,39 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) } let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'deletes the source link and preserves the existing target link' do it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' 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]) described_class.merge_tags!(target_tag, [source_tag])
target_links = PostTag.where(post: post_record, tag: target_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)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(source_pt.discarded_at).to be_present
expect(target_links).to contain_exactly(target_post_tag) expect(source_pt.tag_id).to eq(source_tag.id)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty expect(active.count).to eq(1)
expect(target_post_tag.reload.sections).to contain_exactly(target_section) expect(active.first.id).to eq(target_post_tag.id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_tag_name.reload).to have_attributes( expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
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).to include(
hash_including('language_code' => 'ja', 'name' => source_name,
'primary_flg' => true, 'auto_generated' => false),
hash_including('language_code' => 'ja', 'name' => source_alias.name,
'primary_flg' => false, 'auto_generated' => false))
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.filter_map { |name|
name['name'] if name['language_code'] == 'ja' && !name['primary_flg']
}
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 context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } 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 it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag]) described_class.merge_tags!(target_tag, [source_tag, target_tag])
target_link = PostTag.find_by(post: post_record, 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)
expect(Tag.find(target_tag.id)).to be_present expect(Tag.find(target_tag.id)).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(source_pt.discarded_at).to be_present
expect(target_link).to be_present expect(source_pt.tag_id).to eq(source_tag.id)
expect(source_tag_name.reload).to have_attributes( expect(active_target).to be_present
tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -512,17 +260,18 @@ RSpec.describe Tag, type: :model do
) )
end end
it 'still merges and keeps the source name as an alias without validating it' do it 'still merges, but discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag]) described_class.merge_tags!(target_tag, [source_tag])
target_link = PostTag.find_by(post: post_record, 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)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(source_pt.discarded_at).to be_present
expect(target_link).to be_present expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
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) expect(target_tag.reload.post_count).to eq(1)
end end
end end
@@ -539,30 +288,34 @@ RSpec.describe Tag, type: :model do
message: 'init') message: 'init')
end end
it 'rolls back earlier deletions, links, and history when a later source has a wiki' do it 'rolls back the transaction' 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 { expect {
described_class.merge_tags!(target_tag, [earlier_source, source_tag]) described_class.merge_tags!(target_tag, [source_tag])
}.to raise_error(ActiveRecord::RecordInvalid) }.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.unscoped.exists?(earlier_source.id)).to be(true) expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(earlier_name.reload).to have_attributes( expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
tag_id: earlier_source.id, language_code: 'ja', primary_flg: true) expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id])) expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
.to be_empty expect(source_tag_name.reload.canonical_id).to be_nil
expect(Tag.unscoped.exists?(source_tag.id)).to be(true) expect(target_tag.reload.post_count).to eq(0)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true) end
expect(source_post_tag.reload.tag_id).to eq(source_tag.id) end
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil context 'when merging a nico source tag' do
expect(source_tag.reload.post_count).to eq(1) let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
expect(source_tag_name.reload).to have_attributes( let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
tag_id: source_tag.id, language_code: 'ja', primary_flg: true) 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(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
end end
@@ -580,7 +333,6 @@ RSpec.describe Tag, type: :model do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -612,15 +364,12 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update') expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload)) 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) expect(unaffected_post.reload.post_versions.count).to eq(1)
end end
end end
context 'when the source tag has no post_tags' do context 'when the source tag has no active post_tags' do
let!(:another_post) do let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post') Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end end
+8 -12
ファイルの表示
@@ -1,19 +1,17 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe VersionRecord, type: :model do RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, primary_name: 'version_record_tag') } let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') } let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
it 'makes TagVersion read only after create' do it 'makes TagVersion read only after create' do
version = TagVersion.create!( version = TagVersion.create!(
tag: tag, tag: tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: primary_tag_name_for(tag, 'ja').name, name: tag.name,
category: tag.category, category: tag.category,
aliases: '', aliases: '',
names: [{ language_code: 'ja', name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', primary_flg: true }],
parent_tag_ids: '', parent_tag_ids: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
@@ -29,11 +27,9 @@ RSpec.describe VersionRecord, type: :model do
tag: tag, tag: tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: primary_tag_name_for(tag, 'ja').name, name: tag.name,
category: tag.category, category: tag.category,
aliases: '', aliases: '',
names: [{ language_code: 'ja', name: primary_tag_name_for(tag, 'ja').name,
script_code: 'Jpan', primary_flg: true }],
parent_tag_ids: '', parent_tag_ids: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
@@ -46,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
external_tag: nico_tag, tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: "nico:#{ nico_tag.name }", name: nico_tag.name,
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
@@ -62,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
external_tag: nico_tag, tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: "nico:#{ nico_tag.name }", name: nico_tag.name,
linked_tags: '', linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil 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']).to be_a(Hash)
expect(json['tag']['id']).to eq(tag1.id) expect(json['tag']['id']).to eq(tag1.id)
expect(json['tag']['name']).to eq(primary_tag_name_for(tag1, 'ja').name) expect(json['tag']['name']).to eq(tag1.name)
end end
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 it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member) member = create(:user, :member)
tag = create(:tag, category: :general, primary_name: 'error_response_tag') tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member) sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' } patch "/tags/#{ tag.id }", params: { category: 'nico' }
+8 -7
ファイルの表示
@@ -898,22 +898,23 @@ RSpec.describe 'Gekanator learning API', type: :request do
describe 'GET /gekanator/questions' do describe 'GET /gekanator/questions' do
it 'omits questions for deprecated tags' do it 'omits questions for deprecated tags' do
active_tag = create(:tag, primary_name: 'active_question_tag', category: :general) active_tag = Tag.create!(name: 'active_question_tag', category: :general)
deprecated_tag = create(:tag, deprecated_tag = Tag.create!(
primary_name: 'deprecated_question_tag', name: 'deprecated_question_tag',
category: :general, category: :general,
deprecated_at: Time.current) deprecated_at: Time.current
)
[active_tag, deprecated_tag].each do |question_tag| [active_tag, deprecated_tag].each do |question_tag|
GekanatorQuestion.create!( GekanatorQuestion.create!(
text: "#{ primary_tag_name_for(question_tag, 'ja').name }?", text: "#{ question_tag.name }?",
kind: 'tag', kind: 'tag',
source: 'admin_curated', source: 'admin_curated',
status: 'accepted', status: 'accepted',
priority_weight: 1.0, priority_weight: 1.0,
condition: { condition: {
type: 'tag', type: 'tag',
key: "#{ question_tag.category }:#{ primary_tag_name_for(question_tag, 'ja').name }" key: "#{ question_tag.category }:#{ question_tag.name }"
}, },
created_by: admin created_by: admin
) )
+6 -5
ファイルの表示
@@ -4,11 +4,12 @@ require 'rails_helper'
RSpec.describe 'Gekanator posts API', type: :request do RSpec.describe 'Gekanator posts API', type: :request do
describe 'GET /gekanator/posts' do describe 'GET /gekanator/posts' do
it 'omits deprecated tags and returns the stored similarity cosine' do it 'omits deprecated tags and returns the stored similarity cosine' do
active_tag = create(:tag, primary_name: 'active tag', category: :general) active_tag = Tag.create!(name: 'active tag', category: :general)
deprecated_tag = create(:tag, deprecated_tag = Tag.create!(
primary_name: 'deprecated tag', name: 'deprecated tag',
category: :general, category: :general,
deprecated_at: Time.current) deprecated_at: Time.current
)
post_record = Post.create!(title: 'source', url: 'https://example.com/source') post_record = Post.create!(title: 'source', url: 'https://example.com/source')
target_post = Post.create!(title: 'target', url: 'https://example.com/target') target_post = Post.create!(title: 'target', url: 'https://example.com/target')
-237
ファイルの表示
@@ -1,237 +0,0 @@
require 'rails_helper'
RSpec.describe 'Locale propagation on write paths', type: :request do
include_context 'English locale'
let(:member) { create(:user, :member) }
before do
sign_in_as(member)
end
def expect_localised_tag(name, expected_locale)
tag_name = TagName.find_by!(name:, language_code: expected_locale.language_code)
expect(tag_name).to have_attributes(
script_code: expected_locale.script_code, primary_flg: true)
expect(tag_name.tag_id).to be_present
tag_name.tag
end
['en', nil].each do |requested_locale|
context "with locale #{ requested_locale.inspect }" do
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
it 'creates post tags in the requested language or Japanese fallback' do
post '/posts', params: locale_params.merge(
title: 'Locale post', url: 'https://example.com/locale-post',
tags: 'locale_post_tag', parent_post_ids: '')
expect(response).to have_http_status(:created), response.body
tag = expect_localised_tag('locale_post_tag', expected_locale)
expect(Post.find(json.fetch('id')).tags).to include(tag)
end
it 'updates post tags in the requested language or Japanese fallback' do
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: locale_params.merge(
title: record.title, tags: 'locale_updated_tag', parent_post_ids: '',
base_version_no: record.reload.version_no)
expect(response).to have_http_status(:ok), response.body
tag = expect_localised_tag('locale_updated_tag', expected_locale)
expect(record.reload.tags).to include(tag)
end
it 'passes the resolved locale to bulk creation' do
# Worker propagation is exercised in post_bulk_creator_spec.
expect_any_instance_of(PostBulkCreator).to receive(:run)
.with(expected_locale).and_return(results: [])
manifest = [{ title: 'Bulk locale', url: 'https://example.com/bulk-locale',
tags: 'bulk_locale_tag', parent_post_ids: '' }]
post '/posts/bulk', params: locale_params.merge(posts: JSON.generate(manifest)),
headers: { 'CONTENT_TYPE' => 'multipart/form-data' }
expect(response).to have_http_status(:ok), response.body
end
it 'creates materials with tags in the resolved locale' do
post '/materials', params: locale_params.merge(
tag: 'locale_material', url: 'https://example.com/material')
expect(response).to have_http_status(:created), response.body
tag = expect_localised_tag('locale_material', expected_locale)
expect(Material.find(json.fetch('id')).tag).to eq(tag)
end
it 'updates materials with tags in the resolved locale' do
tag = create(:tag, category: :material)
material = Material.create!(tag:, url: 'https://example.com/material')
put "/materials/#{ material.id }", params: locale_params.merge(
tag: 'locale_material_updated', url: material.url)
expect(response).to have_http_status(:ok), response.body
resolved_tag = expect_localised_tag('locale_material_updated', expected_locale)
expect(material.reload.tag).to eq(resolved_tag)
end
it 'links Nico external tags to internal tags in the resolved locale' do
external = create(:external_tag)
NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: locale_params.merge(tags: 'locale_link')
expect(response).to have_http_status(:ok), response.body
tag = expect_localised_tag('locale_link', expected_locale)
expect(external.reload.linked_tags).to contain_exactly(tag)
end
it 'creates parent tags in the resolved locale' do
tag = create(:tag)
put "/tags/#{ tag.id }", params: locale_params.merge(
name: primary_tag_name_for(tag, 'ja').name, category: tag.category, deprecated: false,
aliases: '', parent_tags: 'locale_parent')
expect(response).to have_http_status(:ok), response.body
parent = expect_localised_tag('locale_parent', expected_locale)
expect(TagImplication.where(tag:).pluck(:parent_tag_id)).to eq([parent.id])
end
end
end
context 'with existing names in both languages' do
let!(:english_name) do
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
end
let!(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
let!(:japanese_tag) do
create(:tag, primary_name: 'temporary_japanese', category: :general).tap do |tag|
# Isolate lookup from the separately tested uniqueness validation.
primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name')
end
end
it 'plans and creates the post using the English identity and category' do
post '/posts', params: {
locale: 'en', title: 'English identity',
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
expect(response).to have_http_status(:created), response.body
record = Post.find(json.fetch('id'))
expect(record.tags).to include(english_tag)
expect(record.tags).not_to include(japanese_tag)
expect(english_tag.reload.category).to eq('material')
expect(japanese_tag.reload.category).to eq('general')
end
it 'updates the post using the English identity' do
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: {
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
base_version_no: record.reload.version_no }
expect(response).to have_http_status(:ok), response.body
expect(record.reload.tags).to include(english_tag)
expect(record.tags).not_to include(japanese_tag)
end
it 'resolves the English material tag' do
post '/materials', params: {
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
expect(response).to have_http_status(:created), response.body
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
expect(Material.where(tag: japanese_tag)).to be_empty
end
it 'resolves the English parent tag' do
child = create(:tag)
put "/tags/#{ child.id }", params: {
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
category: child.category, deprecated: false,
aliases: '', parent_tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
end
it 'resolves the English internal tag for a Nico link' do
external = create(:external_tag)
NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(external.reload.linked_tags).to contain_exactly(english_tag)
end
end
context 'when renaming or updating aliases in English' do
let!(:english_name) do
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
end
let!(:tag) { create(:tag, primary_tag_name: english_name) }
it 'allows a rename to a name already used in Japanese' do
japanese = create(:tag, primary_name: 'rename_target')
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
expect(response).to have_http_status(:ok), response.body
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
name: 'rename_target', language_code: 'en', tag_id: tag.id)
expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes(
name: 'rename_target', language_code: 'ja', tag_id: japanese.id)
end
it 'creates aliases in the requested language' do
put "/tags/#{ tag.id }", params: {
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'english_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
.to have_attributes(
script_code: 'Latn',
tag_id: tag.id, primary_flg: false, language_code: 'en')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
end
it 'does not take an alias from another language' do
japanese = create(:tag, primary_name: 'japanese_owner')
japanese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: japanese)
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.id,
primary_flg: false, language_code: 'ja')
put "/tags/#{ tag.id }", params: {
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'shared_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(japanese_alias.reload).to have_attributes(
tag_id: japanese.id,
primary_flg: false, language_code: 'ja')
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
.to have_attributes(
tag_id: tag.id, script_code: 'Latn',
primary_flg: false, language_code: 'en')
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
.to contain_exactly(['ja', japanese.id], ['en', tag.id])
end
end
end
+23 -25
ファイルの表示
@@ -26,10 +26,10 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials' do describe 'GET /materials' do
let!(:tag_a) do let!(:tag_a) do
create(:tag, primary_name: 'material_index_a', category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material)
end end
let!(:tag_b) do let!(:tag_b) do
create(:tag, primary_name: 'material_index_b', category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material)
end end
let!(:material_a) do let!(:material_a) do
@@ -116,20 +116,20 @@ RSpec.describe 'Materials API', type: :request do
it 'filters by descendant tags and returns stable parent tag groups' do it 'filters by descendant tags and returns stable parent tag groups' do
root = root =
create(:tag, primary_name: 'material_scope_root', Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'),
category: :material) category: :material)
child_b = child_b =
create(:tag, primary_name: 'material_scope_b', Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'),
category: :material) category: :material)
child_a = child_a =
create(:tag, primary_name: 'material_scope_a', Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'),
category: :material) category: :material)
deprecated = deprecated =
create(:tag, primary_name: 'material_scope_old', Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'),
category: :material, category: :material,
deprecated_at: Time.current) deprecated_at: Time.current)
grandchild = grandchild =
create(:tag, primary_name: 'material_scope_grandchild', Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'),
category: :material) category: :material)
root_material = root_material =
build_material(tag: root, user: member_user, build_material(tag: root, user: member_user,
@@ -179,7 +179,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials/:id' do describe 'GET /materials/:id' do
let!(:tag) do let!(:tag) do
create(:tag, primary_name: 'material_show', category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
@@ -269,7 +269,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
material = Material.order(:id).last material = Material.order(:id).last
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_create_new') expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material') expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(member_user) expect(material.created_by_user).to eq(member_user)
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
@@ -297,8 +297,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
tag = TagName.find_by!(name: 'material_create_versioned_tag', tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
language_code: 'ja', primary_flg: true).tag
version = tag.tag_versions.order(:version_no).last version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create') expect(version.event_type).to eq('create')
@@ -324,8 +323,8 @@ RSpec.describe 'Materials API', type: :request do
end end
it 'returns 422 when the existing tag is not material/character' do it 'returns 422 when the existing tag is not material/character' do
general_tag_name = create(:tag_name, name: 'material_create_general_tag') general_tag_name = TagName.create!(name: 'material_create_general_tag')
create(:tag, primary_tag_name: general_tag_name, category: :general) Tag.create!(tag_name: general_tag_name, category: :general)
post '/materials', params: { post '/materials', params: {
tag: 'material_create_general_tag', tag: 'material_create_general_tag',
@@ -346,7 +345,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
material = Material.order(:id).last material = Material.order(:id).last
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_create_url_only') expect(material.tag.name).to eq('material_create_url_only')
expect(material.url).to eq('https://example.com/material-source') expect(material.url).to eq('https://example.com/material-source')
expect(material.file.attached?).to be(false) expect(material.file.attached?).to be(false)
end end
@@ -385,7 +384,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'PUT /materials/:id' do describe 'PUT /materials/:id' do
let!(:tag) do let!(:tag) do
create(:tag, primary_name: 'material_update_old', category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
@@ -464,7 +463,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
material.reload material.reload
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_update_new') expect(material.tag.name).to eq('material_update_new')
expect(material.tag.category).to eq('material') expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source') expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
@@ -498,8 +497,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
tag = TagName.find_by!(name: 'material_update_versioned_tag', tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
language_code: 'ja', primary_flg: true).tag
version = tag.tag_versions.order(:version_no).last version = tag.tag_versions.order(:version_no).last
expect(version.event_type).to eq('create') expect(version.event_type).to eq('create')
@@ -510,7 +508,7 @@ RSpec.describe 'Materials API', type: :request do
it 'backfills a create tag_version for an existing material tag without history' do it 'backfills a create tag_version for an existing material tag without history' do
existing_tag = existing_tag =
create(:tag, primary_name: 'material_update_existing_no_history', Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
category: :material) category: :material)
expect(existing_tag.tag_versions).to be_empty expect(existing_tag.tag_versions).to be_empty
@@ -533,7 +531,7 @@ RSpec.describe 'Materials API', type: :request do
it 'backfills a create tag_version for an existing character tag without history' do it 'backfills a create tag_version for an existing character tag without history' do
existing_tag = existing_tag =
create(:tag, primary_name: 'material_update_character_no_history', Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
category: :character) category: :character)
expect(existing_tag.tag_versions).to be_empty expect(existing_tag.tag_versions).to be_empty
@@ -565,7 +563,7 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
material.reload material.reload
expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_update_remove_file') expect(material.tag.name).to eq('material_update_remove_file')
expect(material.url).to eq('https://example.com/updated-source') expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false) expect(material.file.attached?).to be(false)
@@ -653,8 +651,8 @@ RSpec.describe 'Materials API', type: :request do
end end
describe 'GET /materials/download.zip' do describe 'GET /materials/download.zip' do
let!(:tag_a) { create(:tag, primary_name: 'zip_a', category: :material) } let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) }
let!(:tag_b) { create(:tag, primary_name: 'zip_b', category: :material) } let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) }
let!(:material_a) do let!(:material_a) do
build_material(tag: tag_a, user: member_user, build_material(tag: tag_a, user: member_user,
file: dummy_upload(filename: 'a.png', body: 'zip-a')) file: dummy_upload(filename: 'a.png', body: 'zip-a'))
@@ -703,7 +701,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'GET /materials/versions' do describe 'GET /materials/versions' do
let!(:tag) do let!(:tag) do
create(:tag, primary_name: 'material_history', category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
@@ -778,7 +776,7 @@ RSpec.describe 'Materials API', type: :request do
describe 'DELETE /materials/:id' do describe 'DELETE /materials/:id' do
let!(:tag) do let!(:tag) do
create(:tag, primary_name: 'material_destroy', category: :material) Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material)
end end
let!(:material) do let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
+45 -110
ファイルの表示
@@ -3,34 +3,10 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns the legacy Tag-compatible external fields' do
external = create(:external_tag, name: 'legacy_external', post_count: 3)
get '/tags/nico', params: { name: 'legacy_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including(
'id' => external.id,
'name' => 'nico:legacy_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'linked_tags' => []))
end
it 'returns paginated tags and total count' do it 'returns paginated tags and total count' do
3.times { |i| create(:external_tag, name: "pagination_#{ i }") } create_list(:tag, 3, :nico)
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' } get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1) expect(json['tags'].size).to eq(1)
@@ -38,16 +14,16 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'filters by nico tag name, linked tag name, and link status' do it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:external_tag) linked = create(:tag, :nico)
linked.update!(name: 'search_linked') linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:external_tag) unlinked = create(:tag, :nico)
unlinked.update!(name: 'search_unlinked') unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:external_tag) other = create(:tag, :nico)
other.update!(name: 'other') other.tag_name.update!(name: 'nico:other')
destination = create(:tag, category: :general) destination = create(:tag, :general)
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search') destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination) NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, category: :general)) NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
get '/tags/nico', params: { get '/tags/nico', params: {
name: 'search_', name: 'search_',
@@ -65,77 +41,37 @@ RSpec.describe 'NicoTags', type: :request do
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end end
it 'filters by the qualified legacy name as well as the raw name' do
external = create(:external_tag, name: 'qualified_filter')
create(:external_tag, name: 'unrelated_filter')
['qualified_filter', 'nico:qualified_filter'].each do |name|
get '/tags/nico', params: { name: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter'))
end
end
it 'sorts by name and timestamps' do it 'sorts by name and timestamps' do
older = create(:external_tag) older = create(:tag, :nico)
older.update!(name: 'ordered_a') older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago) older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag) newer = create(:tag, :nico)
newer.update!(name: 'ordered_b') newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago) newer.update_columns(created_at: 1.day.ago)
older_post_tag = older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older) PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago) older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag = newer_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: newer) PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago) newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' } get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' } get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' } get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at'))) expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at) .to be_within(1.second).of(older_post_tag.created_at)
end end
end end
describe 'GET /tags/nico/:id' do
it 'returns the external tag even when an internal tag has the same id' do
internal = create(:tag)
external = create(
:external_tag,
id: internal.id,
name: 'external_detail')
get "/tags/nico/#{ external.id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => external.id,
'name' => 'nico:external_detail',
'category' => 'nico')
end
it 'returns 404 when the external tag does not exist' do
internal = create(:tag)
get "/tags/nico/#{ internal.id }"
expect(response).to have_http_status(:not_found)
end
end
describe 'PATCH /tags/nico/:id' do describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:external_tag) } let(:nico_tag) { create(:tag, :nico) }
it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
@@ -149,31 +85,30 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:forbidden)
end end
it '404 when only an internal tag exists for the target id' do it '400 when target is not nico category' do
sign_in_as(member) sign_in_as(member)
non_nico = create(:tag, category: :general) non_nico = create(:tag, :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:bad_request)
end end
it '200 and updates linked tags while recording tag versions' do it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin) sign_in_as(admin)
nico_tag = create(:external_tag, name: 'nico_tags_spec_source') nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = create(:tag_name, name: 'nico_linked_a') linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = create(:tag, primary_tag_name: linked_a_name, category: :general) linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
linked_b_name = create(:tag_name, name: 'nico_linked_b') linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = create(:tag, primary_tag_name: linked_b_name, category: :general) linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
NicoTagVersionRecorder.record!(external_tag: nico_tag, TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
event_type: :create, created_by_user: admin)
expect { expect {
patch "/tags/nico/#{nico_tag.id}", params: { patch "/tags/nico/#{nico_tag.id}", params: {
tags: " #{ linked_a_name.name }\n#{ linked_b_name.name } " tags: " #{linked_a.name}\n#{linked_b.name} "
} }
}.to change(TagVersion, :count).by(2) }.to change(TagVersion, :count).by(2)
.and change(NicoTagVersion, :count).by(1) .and change(NicoTagVersion, :count).by(1)
@@ -191,26 +126,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([ expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a', 'nico_linked_a',
'nico_linked_b']) 'nico_linked_b'
])
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
it 'clears existing links and records the empty mapping for a member' do it 'returns 422 when linked tag normalises to nico tag' do
sign_in_as(member) sign_in_as(member)
linked = create(:tag)
NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }]) other_nico = create(:tag, :nico, name: 'nico:linked_ng')
NicoTagVersionRecorder.record!(external_tag: nico_tag, TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
event_type: :create, created_by_user: member)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect { expect {
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' } patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.to change(NicoTagVersion, :count).by(1) }.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:unprocessable_entity)
expect(json).to eq([]) expect(json.fetch('errors')).to include(
expect(nico_tag.reload.linked_tags).to be_empty 'tags' => ['ニコニコ・タグ同士は連携できません.'])
expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member)
end end
it 'returns the tags field error when a nico tag is specified directly' do it 'returns the tags field error when a nico tag is specified directly' do
+200 -484
ファイルの表示
@@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do
end end
def create_nico_tag!(name) def create_nico_tag!(name)
ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:')) Tag.find_or_create_by_tag_name!(name, category: :nico)
end end
def dummy_upload def dummy_upload
@@ -55,7 +55,6 @@ RSpec.describe 'Posts API', type: :request do
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms, video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '), tags: post.snapshot_tag_names.join(' '),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -93,28 +92,8 @@ RSpec.describe 'Posts API', type: :request do
count count
end end
def expect_external_tag_json tag_json, external_tag let!(:tag_name) { TagName.create!(name: 'spec_tag') }
external_tag.reload let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
expect(tag_json).to include(
'id' => external_tag.id,
'name' => "#{ external_tag.platform }:#{ external_tag.name }",
'category' => 'nico',
'created_at' => external_tag.created_at.as_json,
'updated_at' => external_tag.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'post_count' => external_tag.post_count,
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'children' => [],
'sections' => [])
end
let!(:tag_name) { create(:tag_name, name: 'spec_tag') }
let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) }
let!(:post_record) do let!(:post_record) do
Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p| Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p|
@@ -125,11 +104,11 @@ RSpec.describe 'Posts API', type: :request do
describe 'GET /posts' do describe 'GET /posts' do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'spec_tag') } let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) } let!(:tag) { Tag.create!(tag_name:, category: :general) }
let!(:tag_name2) { create(:tag_name, name: 'unko') } let!(:tag_name2) { TagName.create!(name: 'unko') }
let!(:tag2) { create(:tag, primary_tag_name: tag_name2, category: :deerjikist) } let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
let!(:hit_post) do let!(:hit_post) do
Post.create!(uploaded_user: user, title: 'hello spec world', Post.create!(uploaded_user: user, title: 'hello spec world',
@@ -168,15 +147,12 @@ RSpec.describe 'Posts API', type: :request do
it 'keeps children and sections keys in non-detail tag responses' do it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil) PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: hit_post, tag: deprecated_tag)
get '/posts' get '/posts'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id } hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
expect(hit_json.fetch('tags').map { |item| item.fetch('id') }).to eq([tag.id])
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' } tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([]) expect(tag_json.fetch('children')).to eq([])
@@ -185,26 +161,6 @@ RSpec.describe 'Posts API', type: :request do
]) ])
end end
it 'preloads tag details and sections as the number of posts grows' do
5.times do
link = create(:post_tag, post: create(:post, uploaded_user: user))
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
get '/posts', params: { limit: 1 }
one_post_queries = count_sql_queries do
get '/posts', params: { limit: 1 }
end
many_post_queries = count_sql_queries do
get '/posts', params: { limit: 20 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').size).to eq(8)
expect(many_post_queries).to be <= one_post_queries
end
context 'when q is provided' do context 'when q is provided' do
it 'filters posts by q (hit case)' do it 'filters posts by q (hit case)' do
get '/posts', params: { tags: 'spec_tag' } get '/posts', params: { tags: 'spec_tag' }
@@ -253,110 +209,18 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
context 'with legacy external tag name searches' do
let!(:external_tag) { create(:external_tag, id: tag.id, name: 'search_external') }
let!(:both_post) do
create(:post).tap do |post|
PostTag.create!(post:, tag:)
PostExternalTag.create!(post:, external_tag:)
end
end
before do
PostExternalTag.create!(post: miss_post, external_tag:)
end
it 'keeps internal name searches independent of colliding external ids' do
get '/posts', params: { tags: primary_tag_name_for(tag, 'ja').name }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, both_post.id)
end
it 'finds posts through PostExternalTag by the qualified legacy name' do
get '/posts', params: { tags: 'nico:search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
[nil, 'all'].each do |match|
it "intersects internal and external matches with match=#{ match || 'omitted' }" do
params = { tags: "#{ primary_tag_name_for(tag, 'ja').name } nico:search_external" }
params[:match] = match if match
get '/posts', params: params
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('posts').map { _1.fetch('id') }).to eq([both_post.id])
end
end
it 'unions internal alias and external matches without duplicate posts' do
get '/posts', params: { tags: 'manko nico:search_external', match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(4)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, miss_post.id, both_post.id)
end
it 'excludes external matches from an internal tag search' do
get '/posts', params: {
tags: "#{ primary_tag_name_for(tag, 'ja').name } not:nico:search_external" }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id)
end
it 'unions an external match with a negated internal match' do
get '/posts', params: {
tags: "nico:search_external not:#{ primary_tag_name_for(tag, 'ja').name }",
match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
it 'keeps a missing qualified external name empty' do
get '/posts', params: { tags: 'nico:missing_search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(0)
expect(json.fetch('posts')).to be_empty
end
it 'applies the same mixed name search to the existing random endpoint' do
get '/posts/random', params: {
tags: "#{ primary_tag_name_for(tag, 'ja').name } nico:search_external",
match: 'all' }
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(both_post.id)
end
end
context 'when tags contain not:' do context 'when tags contain not:' do
let!(:foo_tag_name) { create(:tag_name, name: 'not_spec_foo') } let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
let!(:foo_tag) { create(:tag, primary_tag_name: foo_tag_name, category: :general) } let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
let!(:bar_tag_name) { create(:tag_name, name: 'not_spec_bar') } let!(:bar_tag_name) { TagName.create!(name: 'not_spec_bar') }
let!(:bar_tag) { create(:tag, primary_tag_name: bar_tag_name, category: :general) } let!(:bar_tag) { Tag.create!(tag_name: bar_tag_name, category: :general) }
let!(:baz_tag_name) { create(:tag_name, name: 'not_spec_baz') } let!(:baz_tag_name) { TagName.create!(name: 'not_spec_baz') }
let!(:baz_tag) { create(:tag, primary_tag_name: baz_tag_name, category: :general) } let!(:baz_tag) { Tag.create!(tag_name: baz_tag_name, category: :general) }
let!(:foo_alias_tag_name) do let!(:foo_alias_tag_name) do
create(:tag_name, :alias, name: 'not_spec_foo_alias', tag: foo_tag) TagName.create!(name: 'not_spec_foo_alias', canonical: foo_tag_name)
end end
let!(:foo_only_post) do let!(:foo_only_post) do
@@ -595,71 +459,6 @@ RSpec.describe 'Posts API', type: :request do
end end
end end
context 'when update times include version history' do
let(:t0) { Time.zone.parse('2020-01-01 12:00:00') }
let(:t1) { t0 + 1.day }
let(:t2) { t0 + 2.days }
let(:t3) { t0 + 3.days }
let!(:history_post) do
create(:post, url: 'https://example.com/version-time/history',
created_at: t0, updated_at: t0)
end
let!(:plain_post) do
create(:post, url: 'https://example.com/version-time/plain',
created_at: t1, updated_at: t1)
end
let!(:newer_post) do
create(:post, url: 'https://example.com/version-time/newer',
created_at: t0, updated_at: t3)
end
before do
link = create(:post_tag, post: history_post, tag:)
travel_to(t0) do
PostVersionRecorder.record!(post: history_post,
event_type: :create, created_by_user: nil)
PostVersionRecorder.record!(post: newer_post,
event_type: :create, created_by_user: nil)
end
travel_to(t2) do
link.destroy!
PostVersionRecorder.record!(post: history_post,
event_type: :update, created_by_user: nil)
end
create(:post_tag, post: plain_post, tag:, created_at: t3)
end
['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected_ids = [plain_post.id, history_post.id, newer_post.id]
expected_ids.reverse! if direction == 'desc'
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq(expected_ids)
expect(json.fetch('count')).to eq(3)
times = json.fetch('posts').to_h do |item|
[item.fetch('id'), Time.zone.parse(item.fetch('updated_at'))]
end
expect(times).to eq({ plain_post.id => t1,
history_post.id => t2,
newer_post.id => t3 })
expect(history_post.reload.updated_at).to eq(t0)
end
end
it 'filters inclusively by the latest version time after a tag is deleted' do
get '/posts', params: { url: '/version-time/',
updated_from: t2.iso8601,
updated_to: t2.iso8601 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1)
end
end
context 'when original_created_from/original_created_to are provided' do context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる # 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?` # original_created_from は `original_created_before > ?`
@@ -724,32 +523,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('count')).to eq(2) expect(json.fetch('count')).to eq(2)
end end
end end
it 'returns internal and external tags with colliding ids in the legacy tags array' do
external_tag = create(:external_tag, id: tag.id, name: 'post_index_external')
PostExternalTag.create!(post: hit_post, external_tag:)
get '/posts'
expect(response).to have_http_status(:ok)
post_json =
json
.fetch('posts')
.find { _1.fetch('id') == hit_post.id }
external_json =
post_json
.fetch('tags')
.find { _1['name'] == 'nico:post_index_external' }
expect(post_json.fetch('tags')).to include(
a_hash_including('id' => tag.id,
'name' => primary_tag_name_for(tag, 'ja').name,
'category' => 'general'))
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end end
describe 'GET /posts/:id' do describe 'GET /posts/:id' do
@@ -779,10 +552,11 @@ RSpec.describe 'Posts API', type: :request do
end end
it 'omits deprecated tags' do it 'omits deprecated tags' do
deprecated_tag = create(:tag, deprecated_tag = Tag.create!(
primary_name: 'deprecated_post_tag', name: 'deprecated_post_tag',
category: :general, category: :general,
deprecated_at: Time.current) deprecated_at: Time.current
)
PostTag.create!(post: post_record, tag: deprecated_tag) PostTag.create!(post: post_record, tag: deprecated_tag)
request request
@@ -859,9 +633,9 @@ RSpec.describe 'Posts API', type: :request do
tags = tags =
15.times.map do |i| 15.times.map do |i|
tag_name = create(:tag_name, name: "show_query_tag_#{ i }") tag_name = TagName.create!(name: "show_query_tag_#{ i }")
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = Tag.create!(tag_name:, category: :general)
create(:tag_name, :alias, name: "show_query_alias_#{ i }", tag:) TagName.create!(name: "show_query_alias_#{ i }", canonical: tag_name)
PostTag.create!(post: post_record, tag:) PostTag.create!(post: post_record, tag:)
tag tag
end end
@@ -906,27 +680,6 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(query_count).to be <= 45 expect(query_count).to be <= 45
end end
it 'returns external tags as root nodes in the legacy tag tree' do
external_tag = create(:external_tag, id: tag.id, name: 'post_detail_external')
PostExternalTag.create!(post: post_record, external_tag:)
request
expect(response).to have_http_status(:ok)
expect(json.fetch('tags')).to include(
a_hash_including('id' => tag.id,
'name' => primary_tag_name_for(tag, 'ja').name,
'category' => 'general'))
external_json =
json
.fetch('tags')
.find { _1['name'] == 'nico:post_detail_external' }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end end
context 'when post does not exist' do context 'when post does not exist' do
@@ -1008,7 +761,7 @@ RSpec.describe 'Posts API', type: :request do
describe 'POST /posts' do describe 'POST /posts' do
let(:member) { create(:user, :member) } let(:member) { create(:user, :member) }
let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
it '401 when not logged in' do it '401 when not logged in' do
sign_out sign_out
@@ -1165,10 +918,11 @@ RSpec.describe 'Posts API', type: :request do
end end
it 'rejects a deprecated tag specified directly' do it 'rejects a deprecated tag specified directly' do
create(:tag, Tag.create!(
primary_name: 'deprecated_direct_tag', name: 'deprecated_direct_tag',
category: :general, category: :general,
deprecated_at: Time.current) deprecated_at: Time.current
)
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -1185,16 +939,18 @@ RSpec.describe 'Posts API', type: :request do
end end
it 'expands through multiple deprecated parent tags and saves active ancestors' do it 'expands through multiple deprecated parent tags and saves active ancestors' do
child = create(:tag, primary_name: 'active_child', category: :general) child = Tag.create!(name: 'active_child', category: :general)
deprecated_parent = create(:tag, deprecated_parent = Tag.create!(
primary_name: 'deprecated_parent', name: 'deprecated_parent',
category: :general, category: :general,
deprecated_at: Time.current) deprecated_at: Time.current
deprecated_grandparent = create(:tag, )
primary_name: 'deprecated_grandparent', deprecated_grandparent = Tag.create!(
category: :general, name: 'deprecated_grandparent',
deprecated_at: Time.current) category: :general,
active_grandparent = create(:tag, primary_name: 'active_grandparent', category: :general) deprecated_at: Time.current
)
active_grandparent = Tag.create!(name: 'active_grandparent', category: :general)
TagImplication.create!(tag: child, parent_tag: deprecated_parent) TagImplication.create!(tag: child, parent_tag: deprecated_parent)
TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent)
TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent)
@@ -1208,8 +964,7 @@ RSpec.describe 'Posts API', type: :request do
) )
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
saved_names = TagName.where(tag_id: Post.find(json.fetch('id')).tags.select(:id), saved_names = Post.find(json.fetch('id')).tags.map(&:name)
language_code: 'ja', primary_flg: true).pluck(:name)
expect(saved_names).to include('active_child', 'active_grandparent') expect(saved_names).to include('active_child', 'active_grandparent')
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent') expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
end end
@@ -1312,8 +1067,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id')) created_post = Post.find(json.fetch('id'))
tag = TagName.find_by!(name: '伊地知ニジカ', tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
language_code: 'ja', primary_flg: true).tag
section = PostTagSection.find_by!(post: created_post, tag:) section = PostTagSection.find_by!(post: created_post, tag:)
expect(section.begin_ms).to eq(60_000) expect(section.begin_ms).to eq(60_000)
@@ -1333,8 +1087,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id')) created_post = Post.find(json.fetch('id'))
tag = TagName.find_by!(name: '伊地知ニジカ', tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
language_code: 'ja', primary_flg: true).tag
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end end
@@ -1351,8 +1104,7 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:created) expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id')) created_post = Post.find(json.fetch('id'))
tag = TagName.find_by!(name: '伊地知ニジカ', tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
language_code: 'ja', primary_flg: true).tag
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end end
@@ -1409,9 +1161,11 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when the external nico tag already exists' do context 'when nico tag already exists in tags' do
before do before do
create(:external_tag, name: 'nico_tag') Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1631,7 +1385,6 @@ RSpec.describe 'Posts API', type: :request do
expect(arguments[:thumbnails].keys).to eq([0]) expect(arguments[:thumbnails].keys).to eq([0])
expect(arguments[:host]).to eq('http://www.example.com') expect(arguments[:host]).to eq('http://www.example.com')
end end
expect(creator).to have_received(:run).with(Locale.nipponese)
end end
it 'rejects malformed manifests as a request-level error' do it 'rejects malformed manifests as a request-level error' do
@@ -1665,11 +1418,9 @@ RSpec.describe 'Posts API', type: :request do
it '200 and updates title + resync tags when member' do it '200 and updates title + resync tags when member' do
sign_in_as(member) sign_in_as(member)
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: 2000)
tn2 = create(:tag_name, name: 'spec_tag_2') tn2 = TagName.create!(name: 'spec_tag_2')
replacement_tag = create(:tag, primary_tag_name: tn2, category: :general) Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: post_update_params( put "/posts/#{post_record.id}", params: post_update_params(
post_record, post_record,
@@ -1682,45 +1433,14 @@ RSpec.describe 'Posts API', type: :request do
names = json['tags'].map { |n| n['name'] } names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2') expect(names).to include('spec_tag_2')
expect(names).not_to include('spec_tag')
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
expect(PostTagSection.exists?(post: post_record, tag:)).to be(false)
expect(tag.reload.post_count).to eq(0)
expect(replacement_tag.reload.post_count).to eq(1)
versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include(
a_hash_including('tag_id' => tag.id,
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
expect(versions.last.tags_json.map { |item| item.fetch('tag_id') })
.not_to include(tag.id)
end
it 'can add a removed tag again and records both changes' do
sign_in_as(member)
put "/posts/#{ post_record.id }", params: post_update_params(post_record, tags: '')
expect(response).to have_http_status(:ok)
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
put "/posts/#{ post_record.id }", params: post_update_params(
post_record, tags: 'spec_tag')
expect(response).to have_http_status(:ok)
expect(PostTag.where(post: post_record, tag:).count).to eq(1)
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version|
version.tags_json.map { |item| item.fetch('tag_id') }
end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end end
it 'rejects a deprecated tag specified directly' do it 'rejects a deprecated tag specified directly' do
create(:tag, Tag.create!(
primary_name: 'deprecated_update_tag', name: 'deprecated_update_tag',
category: :general, category: :general,
deprecated_at: Time.current) deprecated_at: Time.current
)
sign_in_as(member) sign_in_as(member)
put "/posts/#{ post_record.id }", params: post_update_params( put "/posts/#{ post_record.id }", params: post_update_params(
@@ -1735,9 +1455,11 @@ RSpec.describe 'Posts API', type: :request do
) )
end end
context 'when the external nico tag already exists' do context 'when nico tag already exists in tags' do
before do before do
create(:external_tag, name: 'nico_tag') Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end end
it 'returns 422 with tag field errors' do it 'returns 422 with tag field errors' do
@@ -1975,7 +1697,7 @@ RSpec.describe 'Posts API', type: :request do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
title: 'updated by me', title: 'updated by me',
tags: "spec_tag #{primary_tag_name_for(Tag.no_deerjikist, 'ja').name}") tags: "spec_tag #{Tag.no_deerjikist.name}")
expect(response).to have_http_status(:conflict) expect(response).to have_http_status(:conflict)
@@ -1995,8 +1717,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
current_tag = Tag.find_or_create_by_tag_name!( current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general)
Locale.nipponese, 'current_added_tag', category: :general)
PostTag.create!(post: post_record, tag: current_tag, created_user: member) PostTag.create!(post: post_record, tag: current_tag, created_user: member)
PostVersionRecorder.record!( PostVersionRecorder.record!(
@@ -2007,8 +1728,7 @@ RSpec.describe 'Posts API', type: :request do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
title: post_record.title, title: post_record.title,
tags: ['spec_tag', primary_tag_name_for(Tag.no_deerjikist, 'ja').name, tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag")
'incoming_added_tag'].join(' '))
expect(response).to have_http_status(:conflict) expect(response).to have_http_status(:conflict)
@@ -2026,8 +1746,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
current_tag = Tag.find_or_create_by_tag_name!( current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general)
Locale.nipponese, 'current_merge_tag', category: :general)
PostTag.create!(post: post_record, tag: current_tag, created_user: member) PostTag.create!(post: post_record, tag: current_tag, created_user: member)
PostVersionRecorder.record!( PostVersionRecorder.record!(
@@ -2038,17 +1757,15 @@ RSpec.describe 'Posts API', type: :request do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
title: post_record.title, title: post_record.title,
tags: ['spec_tag', primary_tag_name_for(Tag.no_deerjikist, 'ja').name, tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag",
'incoming_merge_tag'].join(' '),
merge: '1') merge: '1')
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
names = TagName.where(tag_id: post_record.reload.tags.select(:id), names = post_record.reload.tags.map(&:name)
language_code: 'ja', primary_flg: true).pluck(:name)
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(names).to include(Tag.no_deerjikist.name)
expect(names).to include('current_merge_tag') expect(names).to include('current_merge_tag')
expect(names).to include('incoming_merge_tag') expect(names).to include('incoming_merge_tag')
end end
@@ -2059,7 +1776,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico') nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostVersionRecorder.record!( PostVersionRecorder.record!(
post: post_record.reload, post: post_record.reload,
@@ -2071,58 +1788,47 @@ RSpec.describe 'Posts API', type: :request do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
title: post_record.title, title: post_record.title,
tags: "spec_tag #{ primary_tag_name_for(Tag.no_deerjikist, 'ja').name }") tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
names = TagName.where(tag_id: post_record.reload.tags.select(:id), names = post_record.reload.tags.map(&:name)
language_code: 'ja', primary_flg: true).pluck(:name)
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(names).to include(Tag.no_deerjikist.name)
expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include(nico_tag.name)
end end
it 'keeps nico tags even when they are not included in PUT tags' do it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member) sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico') nico_tag = create_nico_tag!('nico:readonly_update_nico')
PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
title: 'updated title', title: 'updated title',
tags: "spec_tag #{ primary_tag_name_for(Tag.no_deerjikist, 'ja').name }") tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
names = TagName.where(tag_id: post_record.reload.tags.select(:id), names = post_record.reload.tags.map(&:name)
language_code: 'ja', primary_flg: true).pluck(:name)
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(names).to include(Tag.no_deerjikist.name)
expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include(nico_tag.name)
external_json =
json
.fetch('tags')
.find { _1['name'] == "nico:#{ nico_tag.name }" }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, nico_tag)
end end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
sign_in_as(member) sign_in_as(member)
nico_tag = create_nico_tag!('nico:relation_source') nico_tag = create_nico_tag!('nico:relation_source')
linked_tag = Tag.find_or_create_by_tag_name!( linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
Locale.nipponese, 'relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag) NicoTagRelation.create!(nico_tag:, tag: linked_tag)
PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostTag.create!(post: post_record, tag: linked_tag, created_user: member) PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
@@ -2130,17 +1836,16 @@ RSpec.describe 'Posts API', type: :request do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
base_version_no: base_version.version_no, base_version_no: base_version.version_no,
title: post_record.title, title: post_record.title,
tags: "spec_tag #{ primary_tag_name_for(Tag.no_deerjikist, 'ja').name }") tags: "spec_tag #{ Tag.no_deerjikist.name }")
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
names = TagName.where(tag_id: post_record.reload.tags.select(:id), names = post_record.reload.tags.map(&:name)
language_code: 'ja', primary_flg: true).pluck(:name)
expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include(nico_tag.name)
expect(names).to include('spec_tag') expect(names).to include('spec_tag')
expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(primary_tag_name_for(linked_tag, 'ja').name) expect(names).not_to include(linked_tag.name)
end end
it 'force-updates stale posts without base_version_no' do it 'force-updates stale posts without base_version_no' do
@@ -2156,7 +1861,7 @@ RSpec.describe 'Posts API', type: :request do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
title: 'forced title', title: 'forced title',
tags: "spec_tag #{primary_tag_name_for(Tag.no_deerjikist, 'ja').name}", tags: "spec_tag #{Tag.no_deerjikist.name}",
force: '1') force: '1')
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -2173,29 +1878,123 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns viewed state and current tags with their sections' do it '200 and returns viewed boolean' do
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: post_record, tag: deprecated_tag)
get '/posts/random' get '/posts/random'
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json).to have_key('viewed') expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed']) expect([true, false]).to include(json['viewed'])
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => tag.id,
'children' => [],
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }]))
end end
end end
describe 'GET /posts/changes' do describe 'GET /posts/changes' do
it 'returns 404 for the retired history endpoint' do let(:member) { create(:user, :member) }
get '/posts/changes'
expect(response).to have_http_status(:not_found) it 'returns add/remove events (history) for a post' do
# add
tn2 = TagName.create!(name: 'spec_tag2')
tag2 = Tag.create!(tag_name: tn2, category: :general)
pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)
# remove (discard)
pt.discard_by!(member)
get '/posts/changes', params: { id: post_record.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['changes']).to be_an(Array)
expect(json['count']).to be >= 2
types = json['changes'].map { |e| e['change_type'] }.uniq
expect(types).to include('add')
expect(types).to include('remove')
end
it 'filters history by tag' do
tn2 = TagName.create!(name: 'history_tag_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other post',
url: 'https://example.com/history-other'
)
# hit: add
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# hit: add + remove
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss: add + remove
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(3)
changes = json.fetch('changes')
expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id])
expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove])
expect(changes.map { |e| e.dig('post', 'id') }).to match_array([
post_record.id,
other_post.id,
other_post.id
])
end
it 'filters history by post and tag together' do
tn2 = TagName.create!(name: 'history_tag_combo_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_combo_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other combo post',
url: 'https://example.com/history-combo-other'
)
# hit
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# miss by post
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss by tag
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { id: post_record.id, tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(1)
changes = json.fetch('changes')
expect(changes.size).to eq(1)
expect(changes[0]['change_type']).to eq('add')
expect(changes[0].dig('post', 'id')).to eq(post_record.id)
expect(changes[0].dig('tag', 'id')).to eq(tag2.id)
end
it 'returns empty history when tag does not match' do
tn2 = TagName.create!(name: 'history_tag_no_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('changes')).to eq([])
expect(json.fetch('count')).to eq(0)
end end
end end
@@ -2209,8 +2008,8 @@ RSpec.describe 'Posts API', type: :request do
let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) } let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) }
let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) } let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
let!(:tag_name2) { create(:tag_name, name: 'spec_tag_2') } let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') }
let!(:tag2) { create(:tag, primary_tag_name: tag_name2, category: :general) } let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) }
def snapshot_tags(post) def snapshot_tags(post)
post.snapshot_tag_names.join(' ') post.snapshot_tag_names.join(' ')
@@ -2225,7 +2024,6 @@ RSpec.describe 'Posts API', type: :request do
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
tags: snapshot_tags(post), tags: snapshot_tags(post),
tags_json: post.snapshot_tags_json,
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
@@ -2247,7 +2045,7 @@ RSpec.describe 'Posts API', type: :request do
end end
let!(:v2) do let!(:v2) do
post_record.post_tags.find_by!(tag: tag).destroy! post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
PostTag.create!(post: post_record, tag: tag2, created_user: member) PostTag.create!(post: post_record, tag: tag2, created_user: member)
post_record.update!( post_record.update!(
title: 'updated spec post', title: 'updated spec post',
@@ -2356,88 +2154,6 @@ RSpec.describe 'Posts API', type: :request do
expect(first.fetch('created_at')).to eq(t_v1.iso8601) expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end end
context 'with external tag history' do
let(:external_id) { tag.id }
let(:external) { create(:external_tag, id: external_id) }
let(:external_post) { create(:post) }
before do
PostExternalTag.create!(post: external_post, external_tag: external)
PostVersionRecorder.record!(
post: external_post, event_type: :create, created_by_user: member)
unrelated_post = create(:post)
PostExternalTag.create!(post: unrelated_post, external_tag: create(:external_tag))
PostVersionRecorder.record!(
post: unrelated_post, event_type: :create, created_by_user: member)
end
it 'prefers Tag over ExternalTag for the legacy tag parameter' do
get '/posts/versions', params: { tag: tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').map { [_1.fetch('post_id'), _1.fetch('version_no')] })
.to contain_exactly(
[post_record.id, 1], [post_record.id, 2], [other_post_version.post_id, 1])
end
it 'explicitly filters ExternalTag even when its id collides with Tag' do
get '/posts/versions', params: { external_tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
# Temporary compatibility shim until the frontend sends external_tag explicitly.
context 'with legacy tag fallback and no internal Tag with the external id' do
let(:external_id) { Tag.maximum(:id).to_i + 10_000 }
it 'falls back to ExternalTag for the legacy tag parameter' do
expect(Tag.exists?(external.id)).to be(false)
get '/posts/versions', params: { tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
[:tag, :external_tag].each do |parameter|
it "includes external removal history through the legacy API's #{ parameter }" do
external_post.post_external_tags.destroy_all
PostVersionRecorder.record!(
post: external_post.reload, event_type: :update, created_by_user: member)
get '/posts/versions', params: { parameter => external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1),
a_hash_including('post_id' => external_post.id, 'version_no' => 2))
end
end
end
end
it 'can render history containing external identifiers' do
PostExternalTag.create!(post: post_record, external_tag: create(:external_tag))
post_record.update_columns(version_no: 2)
PostVersionRecorder.record!(post: post_record,
event_type: :update, created_by_user: member)
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').first.fetch('tags')).to include(
'name' => primary_tag_name_for(tag2, 'ja').name, 'type' => 'context')
end
it 'filters versions by tag when the current snapshot includes the tag' do it 'filters versions by tag when the current snapshot includes the tag' do
get '/posts/versions', params: { post: post_record.id, tag: tag2.id } get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
@@ -2568,8 +2284,8 @@ RSpec.describe 'Posts API', type: :request do
sign_in_as(member) sign_in_as(member)
base_version = create_post_version_for!(post_record) base_version = create_post_version_for!(post_record)
tag_name2 = create(:tag_name, name: 'spec_tag_2') tag_name2 = TagName.create!(name: 'spec_tag_2')
create(:tag, primary_tag_name: tag_name2, category: :general) Tag.create!(tag_name: tag_name2, category: :general)
expect do expect do
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
@@ -2733,8 +2449,8 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload) base_version = create_post_version_for!(post_record.reload)
tag_name2 = create(:tag_name, name: 'spec_tag_2') tag_name2 = TagName.create!(name: 'spec_tag_2')
tag2 = create(:tag, primary_tag_name: tag_name2, category: :general) tag2 = Tag.create!(tag_name: tag_name2, category: :general)
expect { expect {
put "/posts/#{post_record.id}", params: post_write_params( put "/posts/#{post_record.id}", params: post_write_params(
+58
ファイルの表示
@@ -80,6 +80,38 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
end end
describe "DELETE /tag_children" do describe "DELETE /tag_children" do
@@ -154,5 +186,31 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
end end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end end
end end
+9 -15
ファイルの表示
@@ -3,13 +3,13 @@ require 'rails_helper'
RSpec.describe 'TagVersions API', type: :request do RSpec.describe 'TagVersions API', type: :request do
let(:member) { create(:user, :member, name: 'version member') } let(:member) { create(:user, :member, name: 'version member') }
let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) } let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) }
let!(:other_tag) { create(:tag, primary_name: 'tag_versions_other', category: :general) } let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) }
let!(:parent_shared) { create(:tag, primary_name: 'parent_shared', category: :general) } let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) }
let!(:parent_old) { create(:tag, primary_name: 'parent_old', category: :general) } let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) }
let!(:parent_new) { create(:tag, primary_name: 'parent_new', category: :general) } let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) }
let!(:other_parent) { create(:tag, primary_name: 'other_parent', category: :general) } let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) }
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
@@ -36,12 +36,6 @@ RSpec.describe 'TagVersions API', type: :request do
category: category, category: category,
deprecated_at: deprecated_at, deprecated_at: deprecated_at,
aliases: Array(aliases).join(' '), aliases: Array(aliases).join(' '),
names: [{ language_code: 'ja', name:, script_code: 'Jpan',
primary_flg: true,
auto_generated: false }] + Array(aliases).map { |alias_name|
{ language_code: 'ja', name: alias_name, script_code: 'Jpan',
primary_flg: false, auto_generated: false }
},
parent_tag_ids: Array(parent_tags).map(&:id).join(' '), parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user, created_by_user: created_by_user,
created_at: created_at) created_at: created_at)
@@ -217,7 +211,7 @@ RSpec.describe 'TagVersions API', type: :request do
end end
it 'returns empty when the specified tag has no versions' do it 'returns empty when the specified tag has no versions' do
fresh_tag = create(:tag, primary_name: 'no_versions_tag', category: :general) fresh_tag = create(:tag, name: 'no_versions_tag', category: :general)
get '/tags/versions', params: { id: fresh_tag.id } get '/tags/versions', params: { id: fresh_tag.id }
@@ -238,8 +232,8 @@ RSpec.describe 'TagVersions API', type: :request do
end end
it 'does not create tag versions by wiki updates when tag has no versions yet' do it 'does not create tag versions by wiki updates when tag has no versions yet' do
wiki_tag_name = create(:tag_name, name: 'tag_versions_from_wiki') wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki')
wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general) wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general)
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
+7 -7
ファイルの表示
@@ -8,13 +8,13 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
def create_tag! name:, category: :general def create_tag! name:, category: :general
tag_name = create(:tag_name, name:) tag_name = TagName.create!(name:)
create(:tag, primary_tag_name: tag_name, category:) Tag.create!(tag_name:, category:)
end end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: tag.tag_name,
body:, body:,
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
@@ -43,7 +43,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
wiki_page.reload wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last version = wiki_page.wiki_versions.order(:version_no).last
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') expect(tag.name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after') expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(version).to have_attributes( expect(version).to have_attributes(
@@ -72,7 +72,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
tag.reload tag.reload
wiki_page.reload wiki_page.reload
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only') expect(tag.name).to eq('patch_tag_category_only')
expect(tag.category).to eq('meme') expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end end
@@ -101,7 +101,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
wiki_page.reload wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last version = wiki_page.wiki_versions.order(:version_no).last
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') expect(tag.name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after') expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(version).to have_attributes( expect(version).to have_attributes(
@@ -134,7 +134,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
tag.reload tag.reload
wiki_page.reload wiki_page.reload
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only') expect(tag.name).to eq('put_tag_category_only')
expect(tag.category).to eq('meme') expect(tag.category).to eq('meme')
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end end
+1 -84
ファイルの表示
@@ -12,7 +12,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
let(:guest) { create(:user, role: :guest) } let(:guest) { create(:user, role: :guest) }
before do before do
primary_tag_name_for(tag, 'ja').update!(name: 'deerjika') tag.tag_name.update!(name: 'deerjika')
end end
describe 'GET /tags/:id/deerjikists' do describe 'GET /tags/:id/deerjikists' do
@@ -226,14 +226,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
[platform2, code2], [platform2, code2],
) )
end end
it 'locks the tag before replacing the complete list' do
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
do_request
expect(response).to have_http_status(:ok)
end
end end
context 'when tag already has deerjikists' do context 'when tag already has deerjikists' do
@@ -307,81 +299,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end end
end end
context 'when platform is outside the enum' do
let(:payload) do
[
{ platform: 'invalid', code: code1 },
]
end
it 'returns 422 with an indexed platform error without changing the list' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present],
)
end
end
context 'when a requested deerjikist belongs to another tag' do
let!(:other_tag) { create(:tag, category: :deerjikist) }
let!(:owned_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let!(:conflicting_deerjikist) do
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
end
let(:payload) do
[
{ platform: 'nico', code: 'new-code' },
{ platform: platform2, code: code2 },
]
end
before do
primary_tag_name_for(other_tag, 'ja').update!(name: 'existing-deerjikist')
end
it 'returns an indexed 422 error and rolls back the complete replacement' do
expect {
do_request
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.1.code' => [include('existing-deerjikist')],
)
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
end
end
context 'when a requested deerjikist already belongs to the same tag' do
let!(:existing_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let(:payload) do
[
{ platform: platform1, code: code1 },
]
end
it 'keeps the existing row' do
expect {
do_request
}.not_to change { existing_deerjikist.reload.created_at }
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
.to eq([[platform1, code1]])
end
end
context 'when youtube code is handle' do context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' } let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do let(:payload) do
+278 -458
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読み込み
+2 -2
ファイルの表示
@@ -396,7 +396,7 @@ RSpec.describe 'Theatres API', type: :request do
end end
it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do
tag = create(:tag, primary_name: 'skip-target') tag = create(:tag, name: 'skip-target')
PostTag.create!(post: niconico_post, tag:) PostTag.create!(post: niconico_post, tag:)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member) TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
@@ -503,7 +503,7 @@ RSpec.describe 'Theatres API', type: :request do
end end
it 'returns tag penalties and candidate weights for the current watchers' do it 'returns tag penalties and candidate weights for the current watchers' do
tag = create(:tag, primary_name: 'heavy-tag') tag = create(:tag, name: 'heavy-tag')
PostTag.create!(post: second_niconico_post, tag:) PostTag.create!(post: second_niconico_post, tag:)
event = TheatreSkipEvent.create!( event = TheatreSkipEvent.create!(
theatre:, theatre:,
+1 -1
ファイルの表示
@@ -10,7 +10,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do it 'returns 409 when base_revision_id is stale' do
page = page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'wiki_conflict_request'), tag_name: TagName.create!(name: 'wiki_conflict_request'),
body: 'first', body: 'first',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
+1 -1
ファイルの表示
@@ -10,7 +10,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
def create_wiki_page title:, body: 'body', message: 'init', user: self.user def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: title), tag_name: TagName.create!(name: title),
body:, body:,
created_by_user: user, created_by_user: user,
message:) message:)
+16 -16
ファイルの表示
@@ -10,7 +10,7 @@ RSpec.describe 'Wiki API', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } let!(:tn) { TagName.create!(name: 'spec_wiki_title') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: tn, tag_name: tn,
@@ -19,10 +19,11 @@ RSpec.describe 'Wiki API', type: :request do
message: 'init') message: 'init')
end end
let!(:tag) do let!(:tag) do
create(:tag, Tag.create!(
primary_tag_name: tn, tag_name: tn,
category: :general, category: :general,
deprecated_at: Time.zone.local(2026, 6, 1)) deprecated_at: Time.zone.local(2026, 6, 1)
)
end end
describe 'GET /wiki' do describe 'GET /wiki' do
@@ -207,7 +208,7 @@ RSpec.describe 'Wiki API', type: :request do
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } let!(:test_tag_name) { TagName.create!(name: 'TestPage') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
@@ -274,8 +275,8 @@ RSpec.describe 'Wiki API', type: :request do
end end
it 'wiki body だけを変更しても tag version は作成しない' do it 'wiki body だけを変更しても tag version は作成しない' do
linked_tag_name = create(:tag_name, name: 'wiki_body_only_tag') linked_tag_name = TagName.create!(name: 'wiki_body_only_tag')
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
TagVersionRecorder.record!( TagVersionRecorder.record!(
tag: linked_tag, tag: linked_tag,
@@ -358,13 +359,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'spec_wiki_title_2'), tag_name: TagName.create!(name: 'spec_wiki_title_2'),
body: 'search body 2', body: 'search body 2',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: create(:tag_name, name: 'unrelated_title'), tag_name: TagName.create!(name: 'unrelated_title'),
body: 'unrelated body', body: 'unrelated body',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
@@ -433,7 +434,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns empty array when page has no revisions and filtered by id' do it 'returns empty array when page has no revisions and filtered by id' do
# 別ページを作って revision 無し # 別ページを作って revision 無し
tn2 = create(:tag_name, name: 'spec_no_rev') tn2 = TagName.create!(name: 'spec_no_rev')
# 異常データ: revision 無し WikiPage を直接作る # 異常データ: revision 無し WikiPage を直接作る
p2 = WikiPage.create!( p2 = WikiPage.create!(
tag_name: tn2, tag_name: tn2,
@@ -513,7 +514,7 @@ RSpec.describe 'Wiki API', type: :request do
describe 'Wiki::Commit.redirect!' do describe 'Wiki::Commit.redirect!' do
it 'raises because redirect revisions are deprecated' do it 'raises because redirect revisions are deprecated' do
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target_tag_name = TagName.create!(name: 'redirect_deprecated_target')
target = target =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: target_tag_name, tag_name: target_tag_name,
@@ -534,8 +535,8 @@ RSpec.describe 'Wiki API', type: :request do
end end
it 'wiki title を変更すると対応する tag の version を作成する' do it 'wiki title を変更すると対応する tag の version を作成する' do
linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version') linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version')
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general)
linked_page = linked_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
@@ -563,8 +564,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
linked_tag.reload linked_tag.reload
expect(primary_tag_name_for(linked_tag, 'ja').name) expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed')
.to eq('wiki_linked_tag_for_version_renamed')
versions = linked_tag.tag_versions.order(:version_no) versions = linked_tag.tag_versions.order(:version_no)

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