diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 95ec17a..0023bd5 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -92,4 +92,6 @@ class ApplicationController < ActionController::API value end + + def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese end diff --git a/backend/app/controllers/deerjikists_controller.rb b/backend/app/controllers/deerjikists_controller.rb index 9302370..e6cc209 100644 --- a/backend/app/controllers/deerjikists_controller.rb +++ b/backend/app/controllers/deerjikists_controller.rb @@ -1,5 +1,7 @@ class DeerjikistsController < ApplicationController def show + locale = resolve_locale! + platform = params[:platform].to_s.strip code = params[:code].to_s.strip return render_bad_request('platform は必須です.') if platform.blank? @@ -7,10 +9,10 @@ class DeerjikistsController < ApplicationController deerjikist = Deerjikist .joins(:tag) - .includes(tag: :tag_name) + .includes(tag: :tag_names) .find_by(platform:, code:) if deerjikist - render json: DeerjikistRepr.base(deerjikist) + render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code) else head :not_found end @@ -20,6 +22,7 @@ class DeerjikistsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! platform = params[:platform].to_s.strip code = params[:code].to_s.strip tag_id = params[:tag_id].to_i @@ -32,7 +35,7 @@ class DeerjikistsController < ApplicationController d.save! end - render json: DeerjikistRepr.base(deerjikist) + render json: DeerjikistRepr.base(deerjikist, language_code: locale.language_code) end def destroy diff --git a/backend/app/controllers/gekanator_posts_controller.rb b/backend/app/controllers/gekanator_posts_controller.rb index a176354..b8be760 100644 --- a/backend/app/controllers/gekanator_posts_controller.rb +++ b/backend/app/controllers/gekanator_posts_controller.rb @@ -2,7 +2,7 @@ class GekanatorPostsController < ApplicationController def index posts = Post - .preload(:post_similarities, tags: :tag_name) + .preload(:post_similarities, tags: :tag_names) .with_attached_thumbnail .order(Arel.sql( 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ @@ -56,7 +56,7 @@ class GekanatorPostsController < ApplicationController def tag_json tag { id: tag.id, - name: tag.name, + name: tag.name('ja'), category: tag.category } end diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb index cc08a21..60e8725 100644 --- a/backend/app/controllers/gekanator_questions_controller.rb +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -126,9 +126,9 @@ class GekanatorQuestionsController < ApplicationController end Tag - .joins(:tag_name) + .joins(:tag_names) .where(category: categories.uniq) - .where(tag_names: { name: names.uniq }) + .where(tag_names: { language_code: 'ja', primary_flg: true, name: names.uniq }) .where.not(deprecated_at: nil) .pluck('tags.category', 'tag_names.name') .each_with_object({ }) do |(category, name), h| diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index e4ada01..f94f511 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -7,6 +7,8 @@ class MaterialsController < ApplicationController rescue_from MaterialZipExporter::MissingFileError, with: :render_zip_missing_file def index + locale = resolve_locale! + page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -20,7 +22,7 @@ class MaterialsController < ApplicationController q = Material.includes(:material_export_items, thumbnail_attachment: :blob, file_attachment: :blob, - tag: :tag_name) + tag: :tag_names) q = q.where(tag_id: nil) if filters[:tag_state] == 'untagged' q = q.where.not(tag_id: nil) if filters[:tag_state] == 'tagged' q = apply_material_tag_filter(q, filters, tag_graph) @@ -42,7 +44,10 @@ class MaterialsController < ApplicationController .offset(offset) .to_a - response = { materials: MaterialRepr.list_many(materials, host: request.base_url), + response = { materials: MaterialRepr.list_many( + materials, + host: request.base_url, + language_code: locale.language_code), count: } if filters[:tag_id] response[:tag_scope] = material_index_tag_scope(filters, tag_graph) @@ -55,6 +60,8 @@ class MaterialsController < ApplicationController end def show + locale = resolve_locale! + material = Material .includes(:tag, :material_export_items) @@ -63,15 +70,17 @@ class MaterialsController < ApplicationController .find_by(id: params[:id]) return head :not_found unless material - wiki_page_body = material.tag&.tag_name&.wiki_page&.current_revision&.body + wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body - render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:) + render json: MaterialRepr.base(material, host: request.base_url, + language_code: locale.language_code).merge(wiki_page_body:) end def create return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! tag_name_raw = params[:tag].to_s.strip file = params[:file] file_sha256 = MaterialFileSha256.from_upload(file) @@ -89,13 +98,14 @@ class MaterialsController < ApplicationController begin Material.transaction do - tag = resolve_material_tag!(tag_name_raw) + tag = resolve_material_tag!(locale, tag_name_raw) material = Material.new(tag:, url:, created_by_user: current_user, updated_by_user: current_user) material.file.attach(uploaded_blob) if uploaded_blob material.save! - TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user) + TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user, + language_code: locale.language_code) upsert_export_paths!(material) MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: current_user) @@ -107,7 +117,8 @@ class MaterialsController < ApplicationController if material log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material)) - render json: MaterialRepr.base(material, host: request.base_url), status: :created + render json: MaterialRepr.base(material, host: request.base_url, + language_code: locale.language_code), status: :created else render_validation_error material end @@ -132,12 +143,14 @@ class MaterialsController < ApplicationController block = MaterialImportBlockMatcher.match_for_sha256(file_sha256) return render_material_import_block(block) if block + locale = resolve_locale! + uploaded_blob = build_uploaded_material_blob!(file, file_sha256) begin Material.transaction do MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user) - tag = resolve_material_tag!(tag_name_raw) + tag = resolve_material_tag!(locale, tag_name_raw) material.assign_attributes(tag:, url:, updated_by_user: current_user) if uploaded_blob material.file.attach(uploaded_blob) @@ -145,7 +158,8 @@ class MaterialsController < ApplicationController material.file.detach end material.save! - TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user) + TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user, + language_code: locale.language_code) upsert_export_paths!(material) MaterialVersionRecorder.record!(material:, event_type: :update, created_by_user: current_user) @@ -157,7 +171,8 @@ class MaterialsController < ApplicationController log_thumbnail_generation(material, MaterialThumbnailGenerator.generate!(material)) - render json: MaterialRepr.base(material, host: request.base_url) + render json: MaterialRepr.base(material, host: request.base_url, + language_code: locale.language_code) end def destroy @@ -236,10 +251,8 @@ class MaterialsController < ApplicationController nil end - def resolve_material_tag! tag_name_raw - tag_name = TagName.find_or_create_by!(name: tag_name_raw) - tag = tag_name.tag - tag || Tag.create!(tag_name:, category: :material) + def resolve_material_tag! locale, tag_name_raw + Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material) end def material_index_needs_tag_name? filters @@ -253,7 +266,12 @@ class MaterialsController < ApplicationController end def material_index_join_tag_name q - q.left_joins(tag: :tag_name) + q.left_joins(:tag).joins(<<~SQL.squish) + LEFT JOIN tag_names + ON tag_names.tag_id = tags.id + AND tag_names.language_code = 'ja' + AND tag_names.primary_flg = TRUE + SQL end def material_index_join_file_blob q @@ -269,9 +287,11 @@ class MaterialsController < ApplicationController def apply_material_query q, term like = "%#{ ActiveRecord::Base.sanitize_sql_like(term) }%" - q.where('tag_names.name LIKE :q OR materials.url LIKE :q OR ' \ - 'material_file_blobs.filename LIKE :q', - q: like) + q.where(<<~SQL.squish, q: like) + tag_names.name LIKE :q + OR materials.url LIKE :q + OR material_file_blobs.filename LIKE :q + SQL end def apply_material_media_kind q, media_kind @@ -323,8 +343,9 @@ class MaterialsController < ApplicationController end tags_by_id = Tag - .joins(:tag_name) - .where(id: scope_tag_ids) + .joins(:tag_names) + .where(id: scope_tag_ids, + tag_names: { language_code: 'ja', primary_flg: true }) .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') .each_with_object({ }) do |(id, name, category, deprecated_at), hash| hash[id] = { id:, name:, category:, deprecated: deprecated_at.present? } diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 6f97dbc..64f74ab 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -1,5 +1,7 @@ class NicoTagsController < ApplicationController def index + locale = resolve_locale! + name = params[:name].presence linked_tag = params[:linked_tag].presence link_status = params[:link_status].presence @@ -24,7 +26,7 @@ class NicoTagsController < ApplicationController ExternalTag .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ 'ON post_tag_max.external_tag_id = external_tags.id') - .includes(linked_tags: { tag_name: :wiki_page }) + .includes(linked_tags: { tag_names: :wiki_page }) if name q = q.where(('external_tags.name LIKE ? ' + "OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"), @@ -34,7 +36,8 @@ class NicoTagsController < ApplicationController if linked_tag linked_tag_ids = Tag - .joins(:tag_name) + .joins(:tag_names) + .where(tag_names: { language_code: 'ja', primary_flg: true }) .where('tag_names.name LIKE ?', "%#{ linked_tag }%") .pluck(:id) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) @@ -67,7 +70,9 @@ class NicoTagsController < ApplicationController render json: { tags: tags.map { |tag| external_tag_json(tag).merge( recent_post_tag_created_at: tag.recent_post_tag_created_at, - linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) + linked_tags: tag.linked_tags.map { |lt| + TagRepr.base(lt, language_code: locale.language_code) + }) }, count: } end @@ -81,6 +86,8 @@ class NicoTagsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! + id = params[:id].to_i tag = ExternalTag.find(id) @@ -89,10 +96,14 @@ class NicoTagsController < ApplicationController linked_tags = nil ApplicationRecord.transaction do - linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, - with_no_deerjikist: false) + linked_tags = Tag.normalise_tags!(locale, linked_tag_names, + with_tagme: false, + with_no_deerjikist: false) - TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) + TagVersioning.record_tag_snapshots!( + linked_tags, + created_by_user: current_user, + language_code: locale.language_code) tag.linked_tags = linked_tags tag.save! @@ -102,7 +113,9 @@ class NicoTagsController < ApplicationController created_by_user: current_user) end - render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok + render json: tag.linked_tags.map { |t| + TagRepr.base(t, language_code: locale.language_code) + }, status: :ok rescue Tag::NicoTagNormalisationError render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] } rescue ActiveRecord::RecordInvalid => e diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index b9023b0..3843bb7 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -7,6 +7,8 @@ class PostsController < ApplicationController end def index + locale = resolve_locale! + url = params[:url].presence title = params[:title].presence original_created_from = params[:original_created_from].presence @@ -46,12 +48,12 @@ class PostsController < ApplicationController 'COALESCE(pt_max.max_updated_at, posts.updated_at))' q = - filtered_posts + filtered_posts(locale) .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .preload(:external_tags, :uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_name: :wiki_page }] }]) + { tag_names: :wiki_page }] }]) .with_attached_thumbnail q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -90,7 +92,8 @@ class PostsController < ApplicationController q = q.except(:select, :order) render json: { posts: posts.map { |post| - PostRepr.base(post).merge(updated_at: post.updated_at_all).tap do |json| + PostRepr.base(post, language_code: locale.language_code) + .merge(updated_at: post.updated_at_all).tap do |json| json['thumbnail'] = if post.thumbnail.attached? rails_storage_proxy_url(post.thumbnail, only_path: false) @@ -102,17 +105,19 @@ class PostsController < ApplicationController end def random + locale = resolve_locale! + post = - filtered_posts + filtered_posts(locale) .preload(:uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_name: :wiki_page }] }]) + { tag_names: :wiki_page }] }]) .with_attached_thumbnail .order('RAND()') .first return head :not_found unless post - render json: PostRepr.base(post, current_user) + render json: PostRepr.base(post, current_user, language_code: locale.language_code) end def metadata @@ -187,11 +192,13 @@ class PostsController < ApplicationController end def show + locale = resolve_locale! + post = Post .includes(:uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_name: :wiki_page }] }]) + { tag_names: :wiki_page }] }]) .with_attached_thumbnail .find_by(id: params[:id]) return head :not_found unless post @@ -205,18 +212,21 @@ class PostsController < ApplicationController parent_posts:, child_posts:, sibling_posts:, - related:) - .merge(tags: build_tag_tree_for(post)) + related:, + language_code: locale.language_code) + .merge(tags: build_tag_tree_for(post, locale.language_code)) end def create return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! + preflight = PostCreatePreflight.new( attributes: post_create_attributes, thumbnail: params[:thumbnail], - host: request.base_url).run + host: request.base_url).run(locale) return render json: dry_run_json(preflight) if bool?(:dry) if preflight[:existing_post_id].present? post = Post.new(url: preflight[:url]) @@ -242,10 +252,10 @@ class PostsController < ApplicationController :post_tag_specs, :tag_sections, :normalised_parent_post_ids).symbolize_keys).merge( - thumbnail: params[:thumbnail])).create! + thumbnail: params[:thumbnail])).create!(locale) post.reload - render json: PostRepr.base(post), status: :created + render json: PostRepr.base(post, language_code: locale.language_code), status: :created rescue PostCreatePreflight::ValidationFailed => e render_validation_error fields: e.fields, base: e.base_errors rescue Tag::NicoTagNormalisationError @@ -271,13 +281,18 @@ class PostsController < ApplicationController return head :forbidden unless current_user.gte_member? return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form] return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES + + locale = resolve_locale! + posts = parse_bulk_posts_manifest thumbnails = parse_bulk_thumbnails(posts.length) + result = PostBulkCreator.new( actor: current_user, posts:, thumbnails:, - host: request.base_url).run + host: request.base_url).run(locale) + render json: result rescue JSON::ParserError render_bad_request 'posts manifest の JSON が不正です.' @@ -310,6 +325,7 @@ class PostsController < ApplicationController base_version_no = parse_base_version_no return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no) + locale = Locale.find_by(code: params[:locale].presence) || Locale.nipponese title = params[:title].presence tag_names = params[:tags].to_s.split original_created_from = params[:original_created_from] @@ -329,9 +345,10 @@ class PostsController < ApplicationController base_version = post.post_versions.find_by!(version_no: base_version_no) base_snapshot = post_snapshot_from_version(base_version) - current_snapshot = post_snapshot_from_record(post) + current_snapshot = post_snapshot_from_record(post, locale) end - incoming_snapshot = post_incoming_snapshot(title:, + incoming_snapshot = post_incoming_snapshot(locale:, + title:, original_created_from:, original_created_before:, tag_names:, @@ -360,14 +377,14 @@ class PostsController < ApplicationController end end - apply_post_snapshot!(post, snapshot_to_apply) + apply_post_snapshot!(locale, post, snapshot_to_apply) end return render json: conflict_json, status: :conflict if conflict_json post.reload - json = PostRepr.base(post, current_user) - json['tags'] = build_tag_tree_for(post) + json = PostRepr.base(post, current_user, language_code: locale.language_code) + json['tags'] = build_tag_tree_for(post, locale.language_code) render json:, status: :ok rescue Tag::NicoTagNormalisationError render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } @@ -385,19 +402,19 @@ class PostsController < ApplicationController private - def filtered_posts + def filtered_posts locale tag_names = params[:tags].to_s.split match_type = params[:match] if tag_names.present? - filter_posts_by_tags(tag_names, match_type) + filter_posts_by_tags(locale, tag_names, match_type) else Post.all end end - def filter_posts_by_tags tag_names, match_type + def filter_posts_by_tags locale, tag_names, match_type literals = tag_names.map do |raw_name| - { name: TagName.canonicalise(raw_name.sub(/\Anot:/i, '')).first, + { name: TagName.canonicalise(locale, raw_name.sub(/\Anot:/i, '')).first, negative: raw_name.downcase.start_with?('not:') } end @@ -405,11 +422,11 @@ class PostsController < ApplicationController if match_type == 'any' literals.reduce(Post.none) do |posts, literal| - posts.or(tag_literal_relation(literal[:name], negative: literal[:negative])) + posts.or(tag_literal_relation(locale, literal[:name], negative: literal[:negative])) end else literals.reduce(Post.all) do |posts, literal| - ids = tagged_post_ids_for(literal[:name]) + ids = tagged_post_ids_for(locale, literal[:name]) if literal[:negative] posts.where.not(id: ids) else @@ -419,8 +436,8 @@ class PostsController < ApplicationController end end - def tag_literal_relation name, negative: - ids = tagged_post_ids_for(name) + def tag_literal_relation locale, name, negative: + ids = tagged_post_ids_for(locale, name) if negative Post.where.not(id: ids) else @@ -428,11 +445,11 @@ class PostsController < ApplicationController end end - def tagged_post_ids_for(name) + def tagged_post_ids_for locale, name posts_by_internal_tags = Post - .joins(tags: :tag_name) - .where(tag_names: { name: }) + .joins(tags: :tag_names) + .where(tag_names: { language_code: locale.language_code, name: }) .select(:id) posts_by_external_tags = @@ -474,7 +491,7 @@ class PostsController < ApplicationController end end - def build_tag_tree_for post + def build_tag_tree_for post, language_code = 'ja' post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? } tags = post_tags.map(&:tag) tag_ids = tags.map(&:id) @@ -504,7 +521,7 @@ class PostsController < ApplicationController sections = sections_by_tag_id.fetch(tag_id, []) if path.include?(tag_id) - return TagRepr.inline(tag).merge(children: [], sections:) + return TagRepr.inline(tag, language_code:).merge(children: [], sections:) end if memo.key?(tag_id) @@ -516,7 +533,7 @@ class PostsController < ApplicationController children = child_ids.filter_map { |cid| build_node.(cid, new_path) } - memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:) + memo[tag_id] = TagRepr.inline(tag, language_code:).merge(children:, sections:) end internal_tags = root_ids.filter_map { |id| build_node.call(id, []) } @@ -672,40 +689,46 @@ class PostsController < ApplicationController version.tags_json .select { _1.key?('tag_id') } .map { Post.tag_snapshot_literal(_1) } + .compact .sort end - def post_snapshot_from_record post + def post_snapshot_from_record post, locale { title: post.title, video_ms: post.video_ms, original_created_from: snapshot_time(post.original_created_from), original_created_before: snapshot_time(post.original_created_before), - tag_names: editable_tag_names_from_post(post), + tag_names: editable_tag_names_from_post(post, locale.language_code), parent_post_ids: post.parent_posts.order(:id).pluck(:id) } end - def editable_tag_names_from_post post + def editable_tag_names_from_post post, language_code post .post_tags - .joins(tag: :tag_name) + .joins(tag: :tag_names) .merge(Tag.where(deprecated_at: nil)) - .includes(:sections, tag: :tag_name) + .where(tag_names: { language_code:, primary_flg: true }) + .includes(:sections, tag: :tag_names) .order('tag_names.name') .map do |post_tag| - name = post_tag.tag.tag_name.name + name = post_tag.tag.name(language_code) sections = post_tag.sections.sort_by(&:begin_ms) next name if sections.empty? "#{ name }#{ sections.map { Post.section_literal(_1) }.join }" - end + end.compact.sort end - def post_incoming_snapshot title:, original_created_from:, original_created_before:, + def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:, tag_names:, video_ms_param:, duration_param:, parent_post_ids: validate_original_created_values!(original_created_from, original_created_before) - Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true, - with_sections: true) => { tags:, sections: } + + Tag.normalise_tags!(locale, tag_names, + with_tagme: false, + with_no_deerjikist: false, + deny_deprecated: true, + with_sections: true) => { tags:, sections: } tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:) @@ -716,7 +739,7 @@ class PostsController < ApplicationController original_created_from: snapshot_time(original_created_from), original_created_before: snapshot_time(original_created_before), tag_names: tags.uniq(&:id).map { |tag| - "#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }" + "#{ tag.name(locale.language_code) }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }" }.sort, parent_post_ids: parent_post_ids.sort } end @@ -840,7 +863,7 @@ class PostsController < ApplicationController (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? end - def apply_post_snapshot! post, snapshot + def apply_post_snapshot! locale, post, snapshot PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) post.update!(title: snapshot[:title], @@ -848,11 +871,12 @@ class PostsController < ApplicationController original_created_from: snapshot[:original_created_from], original_created_before: snapshot[:original_created_before]) - Tag.normalise_tags!(snapshot[:tag_names], + Tag.normalise_tags!(locale, snapshot[:tag_names], with_tagme: false, deny_deprecated: true, with_sections: true) => { tags:, sections: } - TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) + TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user, + language_code: locale.language_code) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 243a548..f45d042 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -1,5 +1,6 @@ class TagVersionsController < ApplicationController def index + locale = resolve_locale! tag_id = params[:id].presence page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -18,7 +19,8 @@ class TagVersionsController < ApplicationController SQL .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', 'prev.deprecated_at AS prev_deprecated_at', - 'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids') + 'prev.aliases AS prev_aliases', 'prev.names AS prev_names', + 'prev.parent_tag_ids AS prev_parent_tag_ids') q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id count = q.except(:select, :order, :limit, :offset).count @@ -27,18 +29,20 @@ class TagVersionsController < ApplicationController .limit(limit) .offset(offset) - render json: { versions: serialise_versions(versions), count: } + render json: { versions: serialise_versions(versions, locale.language_code), count: } end private - def serialise_versions rows + def serialise_versions rows, language_code user_ids = rows.map(&:created_by_user_id).compact.uniq users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h rows.map do |row| - cur_aliases = split_values(row.aliases) - prev_aliases = split_values(row.attributes['prev_aliases']) + current_names = names_for_locale(row.names, language_code) + previous_names = names_for_locale(row.attributes['prev_names'], language_code) + cur_aliases = current_names.fetch('aliases', []).map { _1.fetch('name') } + prev_aliases = previous_names.fetch('aliases', []).map { _1.fetch('name') } cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) @@ -47,21 +51,22 @@ class TagVersionsController < ApplicationController tags_by_id = Tag - .includes(:tag_name, :materials, { tag_name: :wiki_page }) + .includes(:tag_names, :materials, tag_names: :wiki_page) .where(id: all_parent_tag_ids) .index_by(&:id) parent_tags = build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) .map do |h| - { tag: TagRepr.base(tags_by_id[h[:tag_id]]), + { tag: TagRepr.base(tags_by_id[h[:tag_id]], language_code:), type: h[:type] } end { tag_id: row.tag_id, version_no: row.version_no, event_type: row.event_type, - name: { current: row.name, prev: row.attributes['prev_name'] }, + names: row.names, + name: { current: current_names['name'], prev: previous_names['name'] }, category: { current: row.category, prev: row.attributes['prev_category'] }, deprecated_at: { current: row.deprecated_at&.iso8601, prev: row.attributes['prev_deprecated_at']&.iso8601 }, @@ -91,5 +96,12 @@ class TagVersionsController < ApplicationController def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) + def names_for_locale names, language_code + values = names.is_a?(String) ? JSON.parse(names) : names + values&.fetch(language_code, {}) || {} + rescue JSON::ParserError + {} + end + def split_parent_tag_ids(values) = split_values(values).map(&:to_i) end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index dae8994..9be0365 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -5,6 +5,8 @@ require 'set' class TagsController < ApplicationController def index + locale = resolve_locale! + post_id = params[:post] name = params[:name].presence @@ -36,9 +38,14 @@ class TagsController < ApplicationController tags = if post_id.present? - Tag.joins(:posts, :tag_name).where(posts: { id: post_id }) + Tag + .joins(:posts, :tag_names) + .where(tag_names: { language_code: locale.language_code, primary_flg: true }, + posts: { id: post_id }) else - Tag.joins(:tag_name) + Tag + .joins(:tag_names) + .where(tag_names: { language_code: locale.language_code, primary_flg: true }) end external_tags = @@ -167,9 +174,10 @@ class TagsController < ApplicationController tags_by_id = Tag - .joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .where(id: tag_ids) + .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 @@ -178,7 +186,7 @@ class TagsController < ApplicationController render json: { tags: rows.map { |row| if row['source'] == 'tag' - TagRepr.base(tags_by_id.fetch(row['id'])) + 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 @@ -190,7 +198,8 @@ class TagsController < ApplicationController parent_tag_id = nil if parent_tag_id <= 0 material_filter = material_filter_param(default: 'any') - graph = build_with_depth_graph(material_filter) + locale = resolve_locale! + graph = build_with_depth_graph(material_filter, locale.language_code) tag_ids = if parent_tag_id @@ -205,6 +214,8 @@ class TagsController < ApplicationController end def autocomplete + locale = resolve_locale! + q = params[:q].to_s.strip.sub(/\Anot:/i, '') prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%" @@ -213,35 +224,36 @@ class TagsController < ApplicationController alias_rows = TagName + .where(language_code: locale.language_code, primary_flg: false) .where('name LIKE ?', prefix) - .where.not(canonical_id: nil) - .pluck(:canonical_id, :name) + .pluck(:tag_id, :name) - matched_alias_by_tag_name_id = { } - canonical_ids = [] - - alias_rows.each do |canonical_id, alias_name| - canonical_ids << canonical_id - matched_alias_by_tag_name_id[canonical_id] ||= alias_name + matched_alias_by_tag_id = { } + tag_ids = [] + alias_rows.each do |tag_id, alias_name| + tag_ids << tag_id + matched_alias_by_tag_id[tag_id] ||= alias_name end base = Tag - .joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .where(deprecated_at: nil) + .joins(:tag_names) + .includes(:tag_names, :materials, tag_names: :wiki_page) + .where(tag_names: { language_code: locale.language_code, primary_flg: true }, + deprecated_at: nil) base = base.where('tags.post_count > 0') if present_only canonical_hit = base.where('tag_names.name LIKE ?', prefix) - internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) + internal_tags = canonical_hit.or(base.where(id: tag_ids.uniq)) internal_rows = internal_tags .order(Arel.sql('tags.post_count DESC, tag_names.name')) .limit(20) .map { |tag| - TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id]) + 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 @@ -257,20 +269,23 @@ class TagsController < ApplicationController .map { external_tag_json(_1) } rows = - (internal_rows + external_rows) - .sort_by { |row| [-row['post_count'], row['name']] } + (internal_rows + external_rows) + .sort_by { |row| [-row['post_count'], row['name'] || row[:name]] } .first(20) render json: rows end def show + locale = resolve_locale! + tag = Tag - .joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(id: params[:id]) - return render json: TagRepr.base(tag) if tag + .joins(:tag_names) + .includes(:tag_names, :materials, tag_names: :wiki_page) + .find_by(id: params[:id], + tag_names: { language_code: locale.language_code, primary_flg: true }) + return render json: TagRepr.base(tag, language_code: locale.language_code) if tag external_tag = ExternalTag.find_by(id: params[:id]) return render json: ExternalTagRepr.base(external_tag) if external_tag @@ -282,12 +297,14 @@ class TagsController < ApplicationController name = params[:name].to_s.strip return render_bad_request('name は必須です.') if name.blank? + locale = resolve_locale! + tag = Tag - .joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(tag_names: { name: }) - return render json: TagRepr.base(tag) if tag + .joins(:tag_names) + .includes(:tag_names, :materials, tag_names: :wiki_page) + .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) + return render json: TagRepr.base(tag, language_code: locale.language_code) if tag platform, external_name = name.split(':', 2) return head :not_found unless external_name @@ -299,35 +316,52 @@ class TagsController < ApplicationController end def deerjikists - tag = Tag.joins(:tag_name) - .includes(:tag_name, tag_name: :wiki_page) - .find_by(id: params[:id]) + locale = resolve_locale! + + tag = + Tag.joins(:tag_names) + .includes(:tag_names, tag_names: :wiki_page) + .find_by(id: params[:id], + tag_names: { language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag - render json: { tag: TagRepr.base(tag), - deerjikists: DeerjikistRepr.many(tag.deerjikists) } + render json: { tag: TagRepr.base(tag, language_code: locale.language_code), + deerjikists: DeerjikistRepr.many( + tag.deerjikists, + language_code: locale.language_code) } end def deerjikists_by_name name = params[:name].to_s.strip return render_bad_request('name は必須です.') if name.blank? - tag = Tag.joins(:tag_name) - .includes(:tag_name, tag_name: :wiki_page) - .find_by(tag_names: { name: }) + locale = resolve_locale! + + tag = + Tag + .joins(:tag_names) + .includes(:tag_names, tag_names: :wiki_page) + .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag - render json: { tag: TagRepr.base(tag), - deerjikists: DeerjikistRepr.many(tag.deerjikists) } + render json: { tag: TagRepr.base(tag, language_code: locale.language_code), + deerjikists: DeerjikistRepr.many( + tag.deerjikists, + language_code: locale.language_code) } end def update_deerjikists return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - tag = Tag.joins(:tag_name) - .includes(:tag_name, tag_name: :wiki_page) - .find_by(id: params[:id]) + locale = resolve_locale! + + tag = + Tag + .joins(:tag_names) + .includes(:tag_names, tag_names: :wiki_page) + .find_by(id: params[:id], + tag_names: { language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag rows = normalise_deerjikist_rows(tag) @@ -346,7 +380,7 @@ class TagsController < ApplicationController row_indexes_by_key.key?([deerjikist.platform, deerjikist.code]) } - render_deerjikist_conflicts(requested_deerjikists, row_indexes_by_key, tag) + render_deerjikist_conflicts(locale, requested_deerjikists, row_indexes_by_key, tag) raise ActiveRecord::Rollback if performed? requested_keys_set = requested_keys.to_set @@ -367,7 +401,7 @@ class TagsController < ApplicationController 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(conflicts, row_indexes_by_key, tag) + render_deerjikist_conflicts(locale, conflicts, row_indexes_by_key, tag) end raise ActiveRecord::Rollback if performed? end @@ -375,22 +409,28 @@ class TagsController < ApplicationController return if performed? - render json: DeerjikistRepr.many(tag.reload.deerjikists) + render json: DeerjikistRepr.many(tag.reload.deerjikists, + language_code: locale.language_code) end def materials_by_name name = params[:name].to_s.strip return render_bad_request('name は必須です.') if name.blank? + + locale = resolve_locale! + material_filter = material_filter_param(default: 'any') - tag = Tag.joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(tag_names: { name: }) + tag = + Tag + .joins(:tag_names) + .includes(:tag_names, :materials, tag_names: :wiki_page) + .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag - graph = build_with_depth_graph(material_filter) + graph = build_with_depth_graph(material_filter, locale.language_code) - render json: build_tag_children(tag, graph:) + render json: build_tag_children(tag, graph:, language_code: locale.language_code) end def update_all @@ -406,7 +446,9 @@ class TagsController < ApplicationController return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) - return unless validate_tag_rename(tag, name) + locale = resolve_locale! + + return unless validate_tag_rename(locale, tag, name) alias_names = params[:aliases].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq @@ -417,24 +459,25 @@ class TagsController < ApplicationController end ApplicationRecord.transaction do - TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user, + language_code: locale.language_code) - old_name = tag.name + old_name = tag.name(locale.language_code) name_changed = name != old_name - wiki_page = tag.tag_name.wiki_page if name_changed + wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed if tag.deprecated? == deprecated tag.update!(category:) else tag.update!(category:, deprecated_at: deprecated ? Time.current : nil) end - rename_tag_name!(tag, name) if name_changed + rename_tag_name!(locale, tag, name) if name_changed - alias_names << old_name if name_changed + alias_names << old_name if name_changed && old_name.present? alias_names.delete(name) - update_aliases!(tag, alias_names) - update_parent_tags!(tag, parent_names) + update_aliases!(locale, tag, alias_names) + update_parent_tags!(locale, tag, parent_names) tag.reload @@ -442,17 +485,20 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, + language_code: locale.language_code, name_changed:, wiki_page:) end - render json: TagRepr.base(tag.reload) + render json: TagRepr.base(tag.reload, language_code: locale.language_code) end def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! + name = params[:name].presence category = params[:category].presence deprecated_given = params.key?(:deprecated) @@ -460,20 +506,21 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - return unless validate_tag_rename(tag, name) + return unless validate_tag_rename(locale, tag, name) if category.present? && category == 'nico' return render_unprocessable_entity 'ニコタグは変更できません.', field: :category end ApplicationRecord.transaction do - TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) + TagVersioning.ensure_snapshot!(tag, created_by_user: current_user, + language_code: locale.language_code) - old_name = tag.name + old_name = tag.name(locale.language_code) name_changed = name.present? && name != old_name - wiki_page = tag.tag_name.wiki_page if name_changed + wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed - rename_tag_name!(tag, name) if name_changed + rename_tag_name!(locale, tag, name) if name_changed tag.update!(category:) if category.present? if deprecated_given && tag.deprecated? != deprecated tag.update!(deprecated_at: deprecated ? Time.current : nil) @@ -485,11 +532,12 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, + language_code: locale.language_code, name_changed:, wiki_page:) end - render json: TagRepr.base(tag.reload) + render json: TagRepr.base(tag.reload, language_code: locale.language_code) end private @@ -501,7 +549,7 @@ class TagsController < ApplicationController value end - def build_with_depth_graph material_filter + def build_with_depth_graph material_filter, language_code children_by_parent_id = Hash.new { |h, k| h[k] = [] } parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } @@ -516,10 +564,13 @@ class TagsController < ApplicationController material_tag_ids = Material.unscoped.kept.where.not(tag_id: nil).distinct.pluck(:tag_id).to_set - tags_by_id = Tag.joins(:tag_name) - .where(id: tag_ids) - .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') - .each_with_object({ }) do |(id, name, category, deprecated_at), h| + tags_by_id = + Tag + .joins(:tag_names) + .where(id: tag_ids, + tag_names: { language_code:, primary_flg: true }) + .pluck('tags.id', 'tag_names.name', 'tags.category', 'tags.deprecated_at') + .each_with_object({ }) do |(id, name, category, deprecated_at), h| h[id] = { name:, category:, deprecated: deprecated_at.present?, has_material: material_tag_ids.include?(id) } end @@ -651,29 +702,33 @@ class TagsController < ApplicationController children: [] } end - def build_tag_children tag, graph: nil + def build_tag_children tag, graph: nil, language_code: 'ja' material = tag.materials.first tag_graph = graph && graph[:tags_by_id][tag.id] material = nil if tag_graph && !with_depth_visible_tag?(tag_graph, graph[:material_filter]) - children = tag.children.sort_by(&:name) + children = tag.children.sort_by { _1.name(language_code) } if graph children = children.filter { |child_tag| visible_subtree?(child_tag.id, graph) } end - TagRepr.base(tag).merge( - children: children.map { build_tag_children(_1, graph:) }, + TagRepr.base(tag, language_code:).merge( + children: children.map { build_tag_children(_1, graph:, language_code:) }, has_material: tag_graph ? tag_graph[:has_material] : material.present?, - material: material && MaterialRepr.base(material, host: request.base_url)) + material: material && MaterialRepr.base( + material, + host: request.base_url, + language_code:)) end - def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil - TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja', + name_changed: false, wiki_page: nil + TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) return unless name_changed - wiki_page ||= tag.tag_name.wiki_page + wiki_page ||= tag.tag_name('ja')&.wiki_page return unless wiki_page&.wiki_versions&.exists? WikiVersionRecorder.record!( @@ -682,30 +737,43 @@ class TagsController < ApplicationController created_by_user:) end - def validate_tag_rename tag, name - return true if name.blank? || name == tag.name + def validate_tag_rename locale, tag, name + return true if name.blank? || name == tag.name(locale.language_code) - if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) + system_tag_names = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ'] + if TagName.exists?(tag_id: tag.id, + language_code: 'ja', + primary_flg: true, + name: system_tag_names) render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name return false end - target_tag_name = TagName.find_by(name:) - return true if target_tag_name.nil? - return true if target_tag_name.canonical_id? + target_tag_name = TagName.find_by(language_code: locale.language_code, name:) + return true unless target_tag_name&.primary_flg render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name false end - def rename_tag_name! tag, name - return if name == tag.name + def rename_tag_name! locale, tag, name + return if name == tag.name(locale.language_code) - current_tag_name = tag.tag_name - target_tag_name = TagName.find_by(name:) + current_tag_name = tag.tag_name(locale.language_code) + target_tag_name = TagName.find_by(language_code: locale.language_code, name:) - if target_tag_name.nil? - current_tag_name.update!(name:) + unless target_tag_name + if current_tag_name + current_tag_name.update!(name:, auto_generated: false) + else + current_tag_name = TagName.create!( + tag:, + language_code: locale.language_code, + script_code: locale.script_code, + name:, + primary_flg: true) + tag.association(:tag_names).reset + end return end @@ -713,72 +781,96 @@ class TagsController < ApplicationController end def promote_tag_alias! tag, current_tag_name:, promoted_tag_name: - old_owner_tag = promoted_tag_name.canonical&.tag + old_owner_tag = promoted_tag_name.tag if old_owner_tag && old_owner_tag != tag TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) end - promoted_tag_name.update!(canonical: nil) + current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) + current_tag_name.association(:wiki_page).reset - TagName.where(canonical_id: current_tag_name.id) + current_tag_name.update!(canonical_id: promoted_tag_name.id, + primary_flg: false, auto_generated: false) + promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false) + + TagName.where(tag:, + language_code: current_tag_name.language_code, + primary_flg: false) .where.not(id: promoted_tag_name.id) .find_each do |alias_tag_name| - alias_tag_name.update!(canonical: promoted_tag_name) + alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) end - current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name) - tag.update!(tag_name: promoted_tag_name) + tag.update!(tag_name_id: promoted_tag_name.id) current_tag_name.association(:wiki_page).reset current_tag_name.association(:tag).reset - current_tag_name.reload.update!(canonical: promoted_tag_name) + current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false) return unless old_owner_tag && old_owner_tag != tag - record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user) + record_tag_version!(old_owner_tag.reload, event_type: :update, + created_by_user: current_user, + language_code: current_tag_name.language_code) end - def update_aliases! tag, alias_names + def update_aliases! locale, tag, alias_names alias_names = alias_names.uniq affected_tags = [tag] - current_aliases = tag.tag_name.aliases.to_a + primary_tag_name = TagName.find_by!(tag:, + language_code: locale.language_code, + primary_flg: true) + current_aliases = tag.tag_names.where(language_code: locale.language_code, + primary_flg: false).to_a current_aliases.each do |alias_tag_name| next if alias_names.include?(alias_tag_name.name) - affected_tags << alias_tag_name.canonical&.tag + affected_tags << alias_tag_name.tag end alias_names.each do |alias_name| - alias_tag_name = TagName.find_or_create_by!(name: alias_name) - affected_tags << alias_tag_name.canonical&.tag + alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name) + if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id + alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.') + raise ActiveRecord::RecordInvalid, alias_tag_name + end + affected_tags << alias_tag_name&.tag end affected_tags.compact.uniq.each do |affected_tag| - TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user) + TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user, + language_code: locale.language_code) end current_aliases.each do |alias_tag_name| next if alias_names.include?(alias_tag_name.name) - alias_tag_name.update!(canonical: nil) + alias_tag_name.update!(canonical_id: nil, tag_id: nil) end alias_names.each do |alias_name| - alias_tag_name = TagName.find_or_create_by!(name: alias_name) - alias_tag_name.update!(canonical: tag.tag_name) + alias_tag_name = TagName.find_or_initialize_by( + language_code: locale.language_code, + name: alias_name) + alias_tag_name.script_code = locale.script_code + alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false, + canonical_id: primary_tag_name.id) end affected_tags.compact.uniq.each do |affected_tag| - record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user) + record_tag_version!(affected_tag, event_type: :update, + created_by_user: current_user, + language_code: locale.language_code) end end - def update_parent_tags! tag, parent_names - parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, - with_no_deerjikist: false) + def update_parent_tags! locale, tag, parent_names + parent_tags = Tag.normalise_tags!(locale, parent_names, + with_tagme: false, + with_no_deerjikist: false) old_parent_tags = tag.parents.to_a @@ -851,15 +943,17 @@ class TagsController < ApplicationController .to_a end - def render_deerjikist_conflicts deerjikists, row_indexes_by_key, tag + 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_name) - .where(id: conflicts.map(&:tag_id).uniq) - .pluck('tags.id', 'tag_names.name') - .to_h + 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| diff --git a/backend/app/controllers/theatre_skip_events_controller.rb b/backend/app/controllers/theatre_skip_events_controller.rb index fbbdae9..c5047c5 100644 --- a/backend/app/controllers/theatre_skip_events_controller.rb +++ b/backend/app/controllers/theatre_skip_events_controller.rb @@ -6,7 +6,7 @@ class TheatreSkipEventsController < ApplicationController events = TheatreSkipEvent .where(theatre_id: params[:theatre_id]) - .includes(:post, tags: :tag_name) + .includes(:post, tags: :tag_names) .order(created_at: :desc) .limit(limit) @@ -14,7 +14,7 @@ class TheatreSkipEventsController < ApplicationController { id: event.id, theatre_id: event.theatre_id, post: { id: event.post.id, title: event.post.title, url: event.post.url }, - tags: event.tags.map { |tag| { id: tag.id, name: tag.name } }, + tags: event.tags.map { |tag| { id: tag.id, name: tag.name('ja') } }, programme_position: event.programme_position, created_at: event.created_at } } diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 9b38b0e..b0d0b8c 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -94,7 +94,11 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - tag_name = TagName.find_or_create_by!(name: title) + tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| + name.script_code = 'Jpan' + name.primary_flg = true + name.auto_generated = false + end page = Wiki::Commit.create_content!( diff --git a/backend/app/models/external_tag.rb b/backend/app/models/external_tag.rb index f49ec54..dc81bef 100644 --- a/backend/app/models/external_tag.rb +++ b/backend/app/models/external_tag.rb @@ -14,7 +14,11 @@ class ExternalTag < ApplicationRecord has_many :linked_tags, through: :nico_tag_relations, source: :tag - def snapshot_linked_tag_names - linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + 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 diff --git a/backend/app/models/language.rb b/backend/app/models/language.rb new file mode 100644 index 0000000..fb2727f --- /dev/null +++ b/backend/app/models/language.rb @@ -0,0 +1,5 @@ +class Language < ApplicationRecord + has_many :locales, foreign_key: :language_code, primary_key: :code + + enum :code, ja: 'ja' +end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb new file mode 100644 index 0000000..1d39f44 --- /dev/null +++ b/backend/app/models/locale.rb @@ -0,0 +1,27 @@ +class Locale < ApplicationRecord + after_create_commit :generate_tag_names! + + belongs_to :language, foreign_key: :language_code, primary_key: :code + belongs_to :script, foreign_key: :script_code, primary_key: :code + + enum :code, ja: 'ja', en: 'en', fr: 'fr' + + def self.nipponese = Locale.find('ja') + + private + + def generate_tag_names! + ApplicationRecord.transaction do + tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) + Tag.where.not(id: tag_ids).find_each do + TagName.create!(tag_id: _1.id, + language_code:, + name: TagName.generate_name(self, _1, _1.tag_name('ja')), + script_code:, + primary_flg: true, + auto_generated: true, + canonical_id: nil) + end + end + end +end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 805e91d..6db6ca8 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -126,13 +126,14 @@ class Post < ApplicationRecord super(options).merge(thumbnail: nil) end - def snapshot_tag_names + def snapshot_tag_names language_code = 'ja' post_tags - .joins(tag: :tag_name) - .includes(:sections, tag: :tag_name) + .joins(tag: :tag_names) + .where(tag_names: { language_code:, primary_flg: true }) + .includes(:sections, tag: :tag_names) .order('tag_names.name') .map do |post_tag| - name = post_tag.tag.tag_name.name + name = post_tag.tag.name(language_code) sections = post_tag.sections.sort_by(&:begin_ms) next name if sections.empty? @@ -152,16 +153,17 @@ class Post < ApplicationRecord "#{ tag.fetch('name') }#{ sections.join }" end - def snapshot_tags_json + def snapshot_tags_json language_code = 'ja' tag_snapshots = post_tags - .joins(tag: :tag_name) - .includes(:sections, tag: :tag_name) + .joins(tag: :tag_names) + .where(tag_names: { language_code:, primary_flg: true }) + .includes(:sections, tag: :tag_names) .order('tags.id') .map { |pt| { 'tag_id' => pt.tag.id, 'version_no' => pt.tag.version_no, - 'name' => pt.tag.name, + '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 } diff --git a/backend/app/models/region.rb b/backend/app/models/region.rb new file mode 100644 index 0000000..85476d6 --- /dev/null +++ b/backend/app/models/region.rb @@ -0,0 +1,3 @@ +class Region < ApplicationRecord + ; +end diff --git a/backend/app/models/script.rb b/backend/app/models/script.rb new file mode 100644 index 0000000..bbc23f1 --- /dev/null +++ b/backend/app/models/script.rb @@ -0,0 +1,5 @@ +class Script < ApplicationRecord + has_many :locales, foreign_key: :script_code, primary_key: :code + + enum :code, Jpan: 'Jpan' +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 02346d4..7891029 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -50,14 +50,10 @@ class Tag < ApplicationRecord has_many :tag_versions - belongs_to :tag_name - delegate :wiki_page, to: :tag_name + has_many :tag_names attribute :version_no, :integer, default: 1 - delegate :name, to: :tag_name, allow_nil: true - validates :tag_name, presence: true - enum :category, deerjikist: 'deerjikist', meme: 'meme', character: 'character', @@ -67,9 +63,8 @@ class Tag < ApplicationRecord validates :category, presence: true, inclusion: { in: Tag.categories.keys } - validate :tag_name_mustnt_start_with_nico - validate :tag_name_must_be_canonical validate :category_must_be_deerjikist_with_deerjikists + validate :tag_names_must_be_valid CATEGORY_PREFIXES = { 'general:' => :general, @@ -83,8 +78,20 @@ class Tag < ApplicationRecord 'mtr:' => :material, 'meta:' => :meta }.freeze - def name= val - (self.tag_name ||= build_tag_name).name = val + SYSTEM_TAG_NAMES = ['タグ希望', 'bot操作', 'ニジラー情報不詳', '動画', 'ニコニコ', 'YouTube'].freeze + + def tag_name language_code + if tag_names.loaded? + tag_names.find { _1.language_code == language_code && _1.primary_flg } + else + tag_names.find_by(language_code:, primary_flg: true) + end + end + def name(language_code) = tag_name(language_code)&.name + def wiki_page = tag_name('ja')&.wiki_page + + def system? + tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES) end def deprecated? = deprecated_at? @@ -95,214 +102,285 @@ class Tag < ApplicationRecord def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? - def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) - def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) - def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) - def self.video = find_or_create_by_tag_name!('動画', category: :meta) - def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) - def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) - - def self.normalise_tags! tag_names, with_tagme: true, - with_no_deerjikist: true, - deny_deprecated: false, - with_sections: false - if tag_names.any? { |n| n.downcase.start_with?('nico:') } - raise NicoTagNormalisationError - end - - sections = { } - tags = tag_names.map do |name| - raw_name = name - pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] - - name = name.sub(/\A#{ pf }/i, '') - - sections_by_tag = [] - while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) - name = "#{ match[1] }#{ match[4] }" - next if match[2].empty? && match[3].empty? - - sections_by_tag << normalise_section_range!( - begin_raw: match[2], - end_raw: match[3], - tag_name: name) - end - - if name.include?('[') || name.include?(']') - raise SectionLiteralParseError.new(raw_name, raw_name) - end - - name = TagName.canonicalise(name).first - - find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| - if deny_deprecated && tag.deprecated? - raise DeprecatedTagNormalisationError, [tag.name] - end - - tag.update!(category: cat) if cat && tag.category != cat - next if sections_by_tag.empty? - - sections[tag.id] ||= [] - sections[tag.id].concat(sections_by_tag) - sections[tag.id] = merge_section_ranges(sections[tag.id]) - sections.delete(tag.id) if sections[tag.id] == [[0, nil]] - end - end - - tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) - tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } - tags.uniq!(&:id) - - if with_sections - { tags:, sections: } - else - tags - end + def snapshot_aliases(language_code) + tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) end - def self.expand_parent_tags tags - return [] if tags.blank? - - seen = Set.new - result = [] - stack = tags.compact.dup - - until stack.empty? - tag = stack.pop - next unless tag - - tag.parents.each do |parent| - next if seen.include?(parent.id) - - seen << parent.id - result << parent - stack << parent - end - end - - (result + tags).uniq { |t| t.id } - end - - def self.normalise_section_range! begin_raw:, end_raw:, tag_name: - begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:) - end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:) - - if end_ms - begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms - end_ms = begin_ms + 1 if begin_ms == end_ms - end - - [begin_ms, end_ms] - end - - def self.merge_section_ranges ranges - sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] } - merged = [] - - sorted_ranges.each do |begin_ms, end_ms| - if merged.empty? - merged << [begin_ms, end_ms] - next - end - - last_begin_ms, last_end_ms = merged[-1] - if last_end_ms.nil? || begin_ms <= last_end_ms - merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)] - else - merged << [begin_ms, end_ms] - end - end - - merged - end - - def self.merge_section_end left_end_ms, right_end_ms - return nil if left_end_ms.nil? || right_end_ms.nil? - - [left_end_ms, right_end_ms].max - end - - def self.find_or_create_by_tag_name! name, category: - tn = TagName.find_or_create_by!(name: name.to_s.strip) - tn = tn.canonical if tn.canonical_id? - - Tag.find_or_create_by!(tag_name_id: tn.id) do |t| - t.category = category - end - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.merge_tags! target_tag, source_tags, created_by_user: nil - target_tag => Tag - - affected_post_ids = Set.new - - Tag.transaction do - TagVersioning.ensure_snapshot!(target_tag, created_by_user:) - - Array(source_tags).compact.uniq.each do |source_tag| - source_tag => Tag - - next if source_tag == target_tag - - TagVersioning.ensure_snapshot!(source_tag, created_by_user:) - - source_tag.post_tags.find_each do |source_pt| - post_id = source_pt.post_id - affected_post_ids << post_id - source_pt.destroy! - unless PostTag.exists?(post_id:, tag: target_tag) - PostTag.create!(post_id:, tag: target_tag) - end - end - - source_tag_name = source_tag.tag_name - - if source_tag_name.wiki_page.present? - raise ActiveRecord::RecordInvalid.new(source_tag_name) - end - - TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) - source_tag.destroy! - - source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, - updated_at: Time.current) - - TagVersioning.record!(target_tag, event_type: :update, created_by_user:) - end - - Post.where(id: affected_post_ids.to_a).find_each do |post| - PostVersionRecorder.ensure_snapshot!(post, created_by_user:) - PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) - end - - # 投稿件数を再集計 - target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count) - end - - target_tag.reload - end - - def snapshot_aliases = tag_name.aliases.order(:name).pluck(:name) - def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) - def snapshot_linked_tag_names - linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + def snapshot_linked_tag_names language_code + linked_tags + .joins(:tag_names) + .where(tag_names: { language_code:, primary_flg: true }) + .order('tag_names.name') + .pluck('tag_names.name') + end + + class << self + def tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta) + def bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta) + def no_deerjikist = + find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta) + def video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta) + def niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta) + def youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta) + + def normalise_tags! locale, tag_names, + with_tagme: true, + with_no_deerjikist: true, + deny_deprecated: false, + with_sections: false + if tag_names.any? { |n| n.downcase.start_with?('nico:') } + raise NicoTagNormalisationError + end + + sections = { } + tags = tag_names.map do |name| + raw_name = name + pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] + + name = name.sub(/\A#{ pf }/i, '') + + sections_by_tag = [] + while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) + name = "#{ match[1] }#{ match[4] }" + next if match[2].empty? && match[3].empty? + + sections_by_tag << normalise_section_range!( + begin_raw: match[2], + end_raw: match[3], + tag_name: name) + end + + if name.include?('[') || name.include?(']') + raise SectionLiteralParseError.new(raw_name, raw_name) + end + + name = TagName.canonicalise(locale, name).first + + find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| + if deny_deprecated && tag.deprecated? + raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)] + end + + tag.update!(category: cat) if cat && tag.category != cat + next if sections_by_tag.empty? + + sections[tag.id] ||= [] + sections[tag.id].concat(sections_by_tag) + sections[tag.id] = merge_section_ranges(sections[tag.id]) + sections.delete(tag.id) if sections[tag.id] == [[0, nil]] + end + end + + tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) + tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } + tags.uniq!(&:id) + + if with_sections + { tags:, sections: } + else + tags + end + end + + def expand_parent_tags tags + return [] if tags.blank? + + seen = Set.new + result = [] + stack = tags.compact.dup + + until stack.empty? + tag = stack.pop + next unless tag + + tag.parents.each do |parent| + next if seen.include?(parent.id) + + seen << parent.id + result << parent + stack << parent + end + end + + (result + tags).uniq { |t| t.id } + end + + def normalise_section_range! begin_raw:, end_raw:, tag_name: + begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:) + end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:) + + if end_ms + begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms + end_ms = begin_ms + 1 if begin_ms == end_ms + end + + [begin_ms, end_ms] + end + + def merge_section_ranges ranges + sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] } + merged = [] + + sorted_ranges.each do |begin_ms, end_ms| + if merged.empty? + merged << [begin_ms, end_ms] + next + end + + last_begin_ms, last_end_ms = merged[-1] + if last_end_ms.nil? || begin_ms <= last_end_ms + merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)] + else + merged << [begin_ms, end_ms] + end + end + + merged + end + + def merge_section_end left_end_ms, right_end_ms + return nil if left_end_ms.nil? || right_end_ms.nil? + + [left_end_ms, right_end_ms].max + end + + def find_or_create_by_tag_name! locale, name, category: + language_code = locale.language_code + name = name.to_s.strip + + ApplicationRecord.transaction do + tn = TagName.find_or_create_by!(language_code:, name:) do + _1.script_code = locale.script_code + _1.primary_flg = true + end + + tag = tn.tag + next tag if tag + + tag = Tag.create!(tag_name_id: tn.id, category:) + tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false) + generate_tag_names!(locale, tag, tn) + tag.association(:tag_names).reset + + tag + end + rescue ActiveRecord::RecordNotUnique + retry + end + + def merge_tags! target_tag, source_tags, created_by_user: nil + target_tag => Tag + + affected_post_ids = Set.new + + Tag.transaction do + TagVersioning.ensure_snapshot!(target_tag, created_by_user:) + + Array(source_tags).compact.uniq.each do |source_tag| + source_tag => Tag + + next if source_tag == target_tag + + TagVersioning.ensure_snapshot!(source_tag, created_by_user:) + + source_tag.post_tags.find_each do |source_pt| + post_id = source_pt.post_id + affected_post_ids << post_id + source_pt.destroy! + unless PostTag.exists?(post_id:, tag: target_tag) + PostTag.create!(post_id:, tag: target_tag) + end + end + + source_tag_name = source_tag.tag_name('ja') + + if source_tag_name.wiki_page.present? + raise ActiveRecord::RecordInvalid.new(source_tag_name) + end + + TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) + source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, + updated_at: Time.current) + source_tag.destroy! + + source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, + updated_at: Time.current) + + TagVersioning.record!(target_tag, event_type: :update, created_by_user:) + end + + Post.where(id: affected_post_ids.to_a).find_each do |post| + PostVersionRecorder.ensure_snapshot!(post, created_by_user:) + PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) + end + + # 投稿件数を再集計 + target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count) + end + + target_tag.reload + end + + def time_to_ms! str, tag_name: + match = + case str + when /\A(?\d+)(?:\.(?\d{1,3}))?\z/ + { hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + when /\A(?\d+):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ + { hours: nil, minutes: Regexp.last_match[:minutes], + seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + when /\A(?\d+):(?[0-5]?\d):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ + { hours: Regexp.last_match[:hours], + minutes: Regexp.last_match[:minutes], + seconds: Regexp.last_match[:seconds], + ms: Regexp.last_match[:ms] } + end + + raise SectionLiteralParseError.new(tag_name, str) unless match + + total_s = match[:seconds].to_i + total_s += match[:minutes].to_i * 60 if match[:minutes] + total_s += match[:hours].to_i * 3_600 if match[:hours] + + total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i + end + + private + + def generate_tag_names! locale, tag, base_tag_name + ApplicationRecord.transaction do + locales = + Locale + .where(deprecated_at: nil) + .where.not(language_code: locale.language_code) + .order(:language_code, :created_at, :script_code) + .uniq { _1.language_code } + locales.each do |locale| + language_code = locale.language_code + script_code = locale.script_code + TagName.create!(tag_id: tag.id, + language_code:, + name: TagName.generate_name(locale, tag, base_tag_name), + script_code:, + primary_flg: true, + auto_generated: true, + canonical_id: nil) + end + end + end end private - def tag_name_mustnt_start_with_nico - if name.to_s.downcase.start_with?('nico:') - errors.add :name, 'タグの命名規則に反してゐます.' - end - end + def tag_names_must_be_valid + tag_names.each do |tag_name| + next if tag_name.valid? - def tag_name_must_be_canonical - if tag_name&.canonical_id? - errors.add :tag_name, 'tag_names へは実体を示す必要があります.' + tag_name.errors[:name].each { errors.add :name, _1 } end end @@ -311,30 +389,4 @@ class Tag < ApplicationRecord errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' end end - - def self.time_to_ms! str, tag_name: - match = - case str - when /\A(?\d+)(?:\.(?\d{1,3}))?\z/ - { hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds], - ms: Regexp.last_match[:ms] } - when /\A(?\d+):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ - { hours: nil, minutes: Regexp.last_match[:minutes], - seconds: Regexp.last_match[:seconds], - ms: Regexp.last_match[:ms] } - when /\A(?\d+):(?[0-5]?\d):(?[0-5]?\d)(?:\.(?\d{1,3}))?\z/ - { hours: Regexp.last_match[:hours], - minutes: Regexp.last_match[:minutes], - seconds: Regexp.last_match[:seconds], - ms: Regexp.last_match[:ms] } - end - - raise SectionLiteralParseError.new(tag_name, str) unless match - - total_s = match[:seconds].to_i - total_s += match[:minutes].to_i * 60 if match[:minutes] - total_s += match[:hours].to_i * 3_600 if match[:hours] - - total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i - end end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index fe12a06..6c119ab 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,43 +1,61 @@ class TagName < ApplicationRecord - has_one :tag + belongs_to :language, foreign_key: :language_code, primary_key: :code + belongs_to :tag, optional: true has_one :wiki_page - belongs_to :canonical, class_name: 'TagName', optional: true - has_many :aliases, class_name: 'TagName', foreign_key: :canonical_id + validates :name, presence: true, + length: { maximum: 255 }, + uniqueness: { scope: :language_code } - validates :name, presence: true, length: { maximum: 255 }, uniqueness: true - - validate :canonical_must_be_canonical validate :alias_name_must_not_have_prefix - validate :canonical_must_not_be_present_with_tag_or_wiki_page + validate :alias_must_not_have_wiki_page validate :name_must_be_sanitised + validate :name_mustnt_start_with_nico + validate :generated_name_must_be_reserved - def self.canonicalise names + def primary? = primary_flg + + def self.canonicalise locale, names names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) return [] if names.blank? - tns = TagName.includes(:canonical).where(name: names).index_by(&:name) + tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) - names.map { |name| tns[name]&.canonical&.name || name }.uniq + names.map { |name| + tag_name = tns[name] + if !tag_name || tag_name.primary? || tag_name.tag_id.nil? + name + else + TagName.find_by(language_code: locale.language_code, + tag_id: tag_name.tag_id, + primary_flg: true)&.name || name + end + }.uniq + end + + def self.generate_name locale, tag, base_tag_name + # TODO: 言語ごとの自動命名ロジック完成したら書く. + + "Tag_##{ tag.id }" end private - def canonical_must_be_canonical - if canonical&.canonical_id? - errors.add :canonical, 'canonical は実体を示す必要があります.' + def generated_name_must_be_reserved + if !auto_generated && name.to_s.start_with?('Tag_#') + errors.add :name, 'タグの命名規則に反してゐます.' end end def alias_name_must_not_have_prefix - if canonical_id? && name.to_s.include?(':') + if !(primary?) && name.to_s.include?(':') errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' end end - def canonical_must_not_be_present_with_tag_or_wiki_page - if canonical_id? && (tag || wiki_page) - errors.add :canonical, 'タグもしくは Wiki の参照がある名前はエーリアスになれません.' + def alias_must_not_have_wiki_page + if !(primary?) && wiki_page + errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.' end end @@ -46,4 +64,10 @@ class TagName < ApplicationRecord errors.add :name, '名前に使用できない文字が含まれてゐます.' end end + + def name_mustnt_start_with_nico + if name.to_s.downcase.start_with?('nico:') + errors.add :name, 'タグの命名規則に反してゐます.' + end + end end diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index 37cde5f..4731d4f 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -15,31 +15,39 @@ class TagNameSanitisationRule < ApplicationRecord end def apply! - TagName.find_each do |tn| - name = sanitise(tn.name) - next if name == tn.name + Language.find_each do |language| + TagName.where(language:).find_each do |tn| + name = sanitise(tn.name) + next if name == tn.name - TagName.transaction do - existing_tn = TagName.find_by(name:) - if existing_tn - existing_tn = existing_tn.canonical || existing_tn - next if existing_tn.id == tn.id + TagName.transaction do + existing_tn = TagName.find_by(language:, name:) + if existing_tn + unless existing_tn.primary_flg + existing_tn = TagName.find_by!(language:, + tag_id: existing_tn.tag_id, + primary_flg: true) + end + next if existing_tn.id == tn.id - existing_tag = Tag.find_by(tag_name_id: existing_tn.id) - source_tag = Tag.find_by(tag_name_id: tn.id) + existing_tag = existing_tn.tag + source_tag = tn.tag - if existing_tag - Tag.merge_tags!(existing_tag, source_tag) if tn.tag - elsif source_tag - source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) + if existing_tag + Tag.merge_tags!(existing_tag, source_tag) if tn.tag + elsif source_tag + tn.update_columns(tag_id: nil, updated_at: Time.current) + source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) + existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current) + end + tn.destroy! + + next end - tn.destroy! - next + # TagName 側の自動サニタイズを回避 + tn.update_columns(name:, updated_at: Time.current) end - - # TagName 側の自動サニタイズを回避 - tn.update_columns(name:, updated_at: Time.current) end end end diff --git a/backend/app/models/tag_version.rb b/backend/app/models/tag_version.rb index 6ca5d0a..8b6e31a 100644 --- a/backend/app/models/tag_version.rb +++ b/backend/app/models/tag_version.rb @@ -11,5 +11,6 @@ class TagVersion < ApplicationRecord meta: 'meta' }, validate: true validates :name, presence: true + validates :names, presence: true validates :category, presence: true end diff --git a/backend/app/representations/deerjikist_repr.rb b/backend/app/representations/deerjikist_repr.rb index fbb73c9..dbe8fb8 100644 --- a/backend/app/representations/deerjikist_repr.rb +++ b/backend/app/representations/deerjikist_repr.rb @@ -6,11 +6,12 @@ module DeerjikistRepr module_function - def base deerjikist - deerjikist.as_json(BASE) + def base deerjikist, language_code: 'ja' + deerjikist.as_json(BASE).merge( + tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code:)) end - def many deerjikists - deerjikists.map { |d| base(d) } + def many deerjikists, language_code: 'ja' + deerjikists.map { |d| base(d, language_code:) } end end diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index 30d21f9..0482569 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -12,14 +12,15 @@ module MaterialRepr module_function - def base material, host: + def base material, host:, language_code: 'ja' material.as_json(BASE).merge( + tag: material.tag && TagRepr.base(material.tag, language_code:), file: if material.file.attached? Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) end, thumbnail: thumbnail_url(material, host:), - thumbnail_fallback_text: thumbnail_fallback_text(material), + thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:), thumbnail_fallback_kind: thumbnail_fallback_kind(material), media_kind: media_kind(material), file_byte_size: material.file_byte_size, @@ -27,11 +28,11 @@ module MaterialRepr export_items: export_items(material)) end - def many materials, host: - materials.map { |m| base(m, host:) } + def many materials, host:, language_code: 'ja' + materials.map { |m| base(m, host:, language_code:) } end - def list material, host: + def list material, host:, language_code: 'ja' { id: material.id, version_no: material.version_no, url: material.url, @@ -40,9 +41,9 @@ module MaterialRepr source_path: material.source_path, source_file_id: material.source_file_id, normalized_source_key: material.normalized_source_key, - tag: compact_tag(material.tag), + tag: compact_tag(material.tag, language_code:), thumbnail: thumbnail_url(material, host:), - thumbnail_fallback_text: thumbnail_fallback_text(material), + thumbnail_fallback_text: thumbnail_fallback_text(material, language_code:), thumbnail_fallback_kind: thumbnail_fallback_kind(material), media_kind: media_kind(material), content_type: material.content_type, @@ -53,8 +54,8 @@ module MaterialRepr export_items: export_items(material) } end - def list_many materials, host: - materials.map { |m| list(m, host:) } + def list_many materials, host:, language_code: 'ja' + materials.map { |m| list(m, host:, language_code:) } end def export_paths material @@ -79,8 +80,8 @@ module MaterialRepr material.thumbnail, host:) end - def thumbnail_fallback_text material - material.tag&.name || material.created_at&.strftime('%Y-%m-%d') + def thumbnail_fallback_text material, language_code: + material.tag&.name(language_code) || material.created_at&.strftime('%Y-%m-%d') end def thumbnail_fallback_kind material @@ -98,11 +99,11 @@ module MaterialRepr 'file_other' end - def compact_tag tag + def compact_tag tag, language_code: return nil unless tag { id: tag.id, - name: tag.name, + name: tag.name(language_code), category: tag.category, deprecated_at: tag.deprecated_at } end diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 01206e4..4784526 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -17,23 +17,23 @@ module PostRepr module_function - def base post, current_user = nil, host: nil + def base post, current_user = nil, host: nil, language_code: 'ja' json = if host.present? common(post, host:) else common(post) end - json['tags'] = tag_json(post) + json['tags'] = tag_json(post, language_code:) json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user) json['viewed'] = current_user ? current_user.viewed?(post) : false json end def detail post, current_user = nil, parent_posts: [], child_posts: [], - sibling_posts: { }, related: [], host: nil + sibling_posts: { }, related: [], host: nil, language_code: 'ja' if host.present? - base(post, current_user, host:).merge( + base(post, current_user, host:, language_code:).merge( 'parent_posts' => cards(parent_posts, host:), 'child_posts' => cards(child_posts, host:), 'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts| @@ -41,7 +41,7 @@ module PostRepr }, 'related' => cards(related, host:)) else - base(post, current_user).merge( + base(post, current_user, language_code:).merge( 'parent_posts' => cards(parent_posts), 'child_posts' => cards(child_posts), 'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts| @@ -86,14 +86,14 @@ module PostRepr end) end - def tag_json post + def tag_json post, language_code: internal_tags = post .post_tags .reject { _1.tag.deprecated? } - .sort_by { _1.tag.name } + .sort_by { TagRepr.name_for(_1.tag, language_code) } .map { |post_tag| - TagRepr.inline(post_tag.tag).merge( + TagRepr.inline(post_tag.tag, language_code:).merge( 'children' => [], 'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) } diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index 3958953..9e3a42e 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -3,18 +3,23 @@ module TagRepr BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at], - methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze + methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze module_function - def base tag - tag.as_json(BASE).merge(aliases: tag.snapshot_aliases, - parents: tag.parents.map { _1.as_json(BASE) }) + def base tag, language_code: 'ja' + tag.as_json(BASE).merge(name: name_for(tag, language_code), + aliases: tag.snapshot_aliases(language_code), + parents: tag.parents.map { inline(_1, language_code:) }) end - def inline tag - tag.as_json(BASE).merge(aliases: [], parents: []) + def inline tag, language_code: 'ja' + tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: []) end - def many(tags) = tags.map { |t| base(t) } + def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) } + + def name_for tag, language_code + tag.name(language_code) || (tag.system? ? tag.name('ja') : nil) + end end diff --git a/backend/app/services/material_version_recorder.rb b/backend/app/services/material_version_recorder.rb index 8f268f7..08d5ad1 100644 --- a/backend/app/services/material_version_recorder.rb +++ b/backend/app/services/material_version_recorder.rb @@ -31,7 +31,7 @@ class MaterialVersionRecorder < VersionRecorder { url: @record.url, parent: @record.parent, tag: @record.tag, - tag_name: @record.tag&.name, + tag_name: @record.tag&.name('ja'), tag_category: @record.tag&.category, source_kind: @record.source_kind, source_uri: @record.source_uri, diff --git a/backend/app/services/post_bulk_creator.rb b/backend/app/services/post_bulk_creator.rb index 1e75e24..38ec06e 100644 --- a/backend/app/services/post_bulk_creator.rb +++ b/backend/app/services/post_bulk_creator.rb @@ -6,7 +6,7 @@ class PostBulkCreator @host = host end - def run + def run locale results = Array.new(@posts.length) mutex = Mutex.new next_index = 0 @@ -27,7 +27,7 @@ class PostBulkCreator break if index >= @posts.length attributes = @posts[index] - results[index] = create_row(actor, attributes, index) + results[index] = create_row(locale, actor, attributes, index) rescue StandardError => e Rails.logger.error( "post_bulk_creator_worker_failure #{ { error: e.class.name, @@ -61,12 +61,12 @@ class PostBulkCreator private - def create_row actor, attributes, index + def create_row locale, actor, attributes, index preflight = PostCreatePreflight.new( attributes: attributes, thumbnail: thumbnail_for(index, attributes), - host: @host).run + host: @host).run(locale) if preflight[:existing_post_id].present? return { status: 'skipped', @@ -76,7 +76,7 @@ class PostBulkCreator post = PostCreator.new( actor: actor, - attributes: normalised_attributes(attributes, preflight, index)).create! + attributes: normalised_attributes(attributes, preflight, index)).create!(locale) result = { status: 'created', post: { id: post.id } } diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index fedd574..58fbaf2 100644 --- a/backend/app/services/post_create_plan.rb +++ b/backend/app/services/post_create_plan.rb @@ -8,13 +8,13 @@ class PostCreatePlan @existing_tags_by_name = nil end - def build! - direct_tag_specs, tag_sections = parse_direct_tag_specs - default_tag_specs = build_default_tag_specs(direct_tag_specs) + def build! locale + direct_tag_specs, tag_sections = parse_direct_tag_specs(locale) + default_tag_specs = build_default_tag_specs(locale, direct_tag_specs) snapshot_tag_specs = merge_tag_specs(direct_tag_specs + default_tag_specs) - preload_existing_tags_by_name!(snapshot_tag_specs.map { _1[:name] }) - validate_new_tag_specs!(snapshot_tag_specs) - post_tag_specs = expand_parent_tag_specs(snapshot_tag_specs) + preload_existing_tags_by_name!(locale, snapshot_tag_specs.map { _1[:name] }) + validate_new_tag_specs!(locale, snapshot_tag_specs) + post_tag_specs = expand_parent_tag_specs(locale, snapshot_tag_specs) video_ms = normalise_video_ms(snapshot_tag_specs) validate_video_sections!(video_ms, tag_sections) parent_post_ids = normalise_parent_post_ids @@ -42,14 +42,16 @@ class PostCreatePlan def tag_names = @attributes[:tags].to_s.split - def parse_direct_tag_specs + def parse_direct_tag_specs locale tag_sections = { } direct_tag_specs = [] tag_names.each do |raw_name| - tag_name, category, sections = parse_raw_tag_name(raw_name) - existing_tag = existing_tags_by_name[tag_name] - raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? + tag_name, category, sections = parse_raw_tag_name(locale, raw_name) + existing_tag = existing_tags_by_name(locale)[tag_name] + if existing_tag&.deprecated? + raise Tag::DeprecatedTagNormalisationError, [existing_tag.name(locale.language_code)] + end direct_tag_specs << { name: tag_name, @@ -65,7 +67,7 @@ class PostCreatePlan [merge_tag_specs(direct_tag_specs), tag_sections] end - def parse_raw_tag_name raw_name + def parse_raw_tag_name locale, raw_name name = raw_name.to_s prefix, category = Tag::CATEGORY_PREFIXES.find { @@ -74,7 +76,7 @@ class PostCreatePlan name = name.sub(/\A#{ prefix }/i, '') sections = [] - while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/)) + while match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/) name = "#{ match[1] }#{ match[4] }" next if match[2].empty? && match[3].empty? @@ -87,17 +89,17 @@ class PostCreatePlan raise Tag::SectionLiteralParseError.new(raw_name, raw_name) end - [resolved_tag_name(name), category&.to_sym, sections] + [resolved_tag_name(locale, name), category&.to_sym, sections] end - def build_default_tag_specs direct_tag_specs + def build_default_tag_specs locale, direct_tag_specs default_tag_specs = [] if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME } default_tag_specs << { name: TAGME_TAG_NAME, category: :meta } end - if direct_tag_specs.none? { deerjikist_tag_spec?(_1) } + if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) } default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta } @@ -106,17 +108,18 @@ class PostCreatePlan default_tag_specs end - def validate_new_tag_specs! specs + def validate_new_tag_specs! locale, specs Array(specs).each do |spec| - next if existing_tags_by_name.key?(spec[:name]) + next if existing_tags_by_name(locale).key?(spec[:name]) - validate_new_tag_spec!(spec) + validate_new_tag_spec!(locale, spec) end end - def validate_new_tag_spec! spec - tag_name = TagName.new(name: spec[:name]) - tag = Tag.new(category: spec[:category], tag_name:) + def validate_new_tag_spec! locale, spec + tag_name = TagName.new(name: spec[:name], language_code: locale.language_code, + script_code: locale.script_code, primary_flg: true) + tag = Tag.new(category: spec[:category]) return if tag_name.valid? && tag.valid? post = Post.new @@ -131,13 +134,15 @@ class PostCreatePlan raise ActiveRecord::RecordInvalid, post end - def expand_parent_tag_specs snapshot_tag_specs - existing_snapshot_tags = snapshot_tag_specs.filter_map { existing_tags_by_name[_1[:name]] } + def expand_parent_tag_specs locale, snapshot_tag_specs + existing_snapshot_tags = snapshot_tag_specs.filter_map do + existing_tags_by_name(locale)[_1[:name]] + end expanded_parent_specs = Tag.expand_parent_tags(existing_snapshot_tags) .reject(&:deprecated?) .map { |tag| - { name: tag.name, + { name: tag.name(locale.language_code), category: tag.category.to_sym } } merge_tag_specs(snapshot_tag_specs + expanded_parent_specs) @@ -156,33 +161,37 @@ class PostCreatePlan }.values.sort_by { _1[:name] } end - def existing_tags_by_name + def existing_tags_by_name locale @existing_tags_by_name ||= begin - names = tag_names.map { canonical_tag_name_without_sections(_1) }.uniq - Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name) + names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq + TagName.where(language_code: locale.language_code, primary_flg: true, name: names) + .includes(:tag) + .each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag } end end - def preload_existing_tags_by_name! names + def preload_existing_tags_by_name! locale, names wanted_names = Array(names).map { _1.to_s }.reject(&:blank?).uniq - missing_names = wanted_names - existing_tags_by_name.keys + missing_names = wanted_names - existing_tags_by_name(locale).keys return if missing_names.empty? - existing_tags_by_name.merge!( - Tag.joins(:tag_name) - .where(tag_names: { name: missing_names }) - .index_by(&:name)) + existing_tags_by_name(locale).merge!( + TagName.where(language_code: locale.language_code, + primary_flg: true, + name: missing_names) + .includes(:tag) + .each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }) end - def canonical_tag_name_without_sections raw_name - name, = parse_raw_tag_name(raw_name) + def canonical_tag_name_without_sections locale, raw_name + name, = parse_raw_tag_name(locale, raw_name) name end - def deerjikist_tag_spec? spec + def deerjikist_tag_spec? locale, spec return true if spec[:category] == :deerjikist - existing_tags_by_name[spec[:name]]&.deerjikist? + existing_tags_by_name(locale)[spec[:name]]&.deerjikist? end def normalise_parent_post_ids @@ -260,10 +269,12 @@ class PostCreatePlan end end - def resolved_tag_name name - tag_name = TagName.includes(:canonical).find_by(name:) - return name if tag_name.nil? + def resolved_tag_name locale, name + tag_name = TagName.find_by(language_code: locale.language_code, name:) + return name if !(tag_name) || tag_name.primary? - (tag_name.canonical || tag_name).name + TagName.find_by(language_code: locale.language_code, + tag_id: tag_name.tag_id, + primary_flg: true)&.name || name end end diff --git a/backend/app/services/post_create_preflight.rb b/backend/app/services/post_create_preflight.rb index 04d3492..c27fc3c 100644 --- a/backend/app/services/post_create_preflight.rb +++ b/backend/app/services/post_create_preflight.rb @@ -15,9 +15,10 @@ class PostCreatePreflight @host = host end - def run + def run locale preview = PostImportPreviewer.new.preview_rows( rows: [preview_row], + locale:, fetch_metadata: false).first if preview[:existing_post_id].present? return { @@ -53,7 +54,7 @@ class PostCreatePreflight original_created_from: preview[:attributes]['original_created_from'], original_created_before: preview[:attributes]['original_created_before'], duration: preview[:attributes]['duration'], - video_ms: preview[:attributes]['video_ms'] }).build! + video_ms: preview[:attributes]['video_ms'] }).build!(locale) { url: plan[:url], diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index 788a671..fe3e6a1 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -9,7 +9,7 @@ class PostCreator @field_warnings = { } end - def create! + def create! locale thumbnail_attachment = prepare_thumbnail_attachment post = Post.new(title: @attributes[:title].presence, url: @attributes[:url], @@ -21,14 +21,17 @@ class PostCreator ApplicationRecord.transaction do post.save! post.thumbnail.attach(thumbnail_attachment) if thumbnail_attachment.present? - snapshot_tags = planned_snapshot_tags - post_tags = planned_post_tags - sections = planned_sections - TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor) - post.video_ms = planned_video_ms + snapshot_tags = planned_snapshot_tags(locale) + post_tags = planned_post_tags(locale) + sections = planned_sections(locale) + TagVersioning.record_tag_snapshots!( + snapshot_tags, + created_by_user: @actor, + language_code: locale.language_code) + post.video_ms = planned_video_ms(locale) post.save! sync_post_tags!(post, post_tags, sections) - sync_parent_posts!(post, planned_parent_post_ids) + sync_parent_posts!(post, planned_parent_post_ids(locale)) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: @actor) end post @@ -45,69 +48,73 @@ class PostCreator thumbnail_base: @attributes[:thumbnail_base].presence) end - def planned_snapshot_tags = planned_create_attributes[:snapshot_tags] + def planned_snapshot_tags(locale) = planned_create_attributes(locale)[:snapshot_tags] - def planned_post_tags = planned_create_attributes[:post_tags] + def planned_post_tags(locale) = planned_create_attributes(locale)[:post_tags] - def planned_sections = planned_create_attributes[:tag_sections] + def planned_sections(locale) = planned_create_attributes(locale)[:tag_sections] - def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids] - - def planned_video_ms - planned_create_attributes[:video_ms] + def planned_parent_post_ids locale + planned_create_attributes(locale)[:normalised_parent_post_ids] end - def planned_create_attributes + def planned_video_ms locale + planned_create_attributes(locale)[:video_ms] + end + + def planned_create_attributes locale @planned_create_attributes ||= begin if @attributes.key?(:snapshot_tag_specs) - snapshot_tags = materialise_tags(@attributes[:snapshot_tag_specs] || []) - post_tags = materialise_tags(@attributes[:post_tag_specs] || []) + snapshot_tags = materialise_tags(locale, @attributes[:snapshot_tag_specs] || []) + post_tags = materialise_tags(locale, @attributes[:post_tag_specs] || []) { snapshot_tags: snapshot_tags, post_tags: post_tags, tag_sections: materialise_sections( @attributes[:tag_sections] || { }, snapshot_tags, - post_tags), + post_tags, + locale), normalised_parent_post_ids: @attributes[:normalised_parent_post_ids] || [], video_ms: @attributes[:video_ms] } else - build_materialised_plan + build_materialised_plan(locale) end end end - def build_materialised_plan - plan = PostCreatePlan.new(attributes: @attributes).build! - snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || []) - post_tags = materialise_tags(plan[:post_tag_specs] || []) + def build_materialised_plan locale + plan = PostCreatePlan.new(attributes: @attributes).build!(locale) + snapshot_tags = materialise_tags(locale, plan[:snapshot_tag_specs] || []) + post_tags = materialise_tags(locale, plan[:post_tag_specs] || []) { snapshot_tags: snapshot_tags, post_tags: post_tags, tag_sections: materialise_sections( plan[:tag_sections] || { }, snapshot_tags, - post_tags), + post_tags, + locale), normalised_parent_post_ids: plan[:normalised_parent_post_ids] || [], video_ms: plan[:video_ms] } end - def materialise_tags specs + def materialise_tags locale, specs Array(specs).each_with_object({ }) do |spec, tags| name = spec[:name] || spec['name'] category = spec[:category] || spec['category'] next if name.blank? || category.blank? - tag = Tag.find_or_create_by_tag_name!(name, category:) + tag = Tag.find_or_create_by_tag_name!(locale, name, category:) tag.update!(category:) if tag.category.to_sym != category.to_sym tags[name] ||= tag end.values end - def materialise_sections sections_by_name, snapshot_tags, post_tags - tags_by_name = post_tags.index_by(&:name) + def materialise_sections sections_by_name, snapshot_tags, post_tags, locale = Locale.nipponese + tags_by_name = post_tags.index_by { _1.name(locale.language_code) } snapshot_tags.each do |tag| - tags_by_name[tag.name] ||= tag + tags_by_name[tag.name(locale.language_code)] ||= tag end sections_by_name.each_with_object({ }) do |(tag_name, ranges), sections| diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index 0a812f5..2431ded 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -16,7 +16,7 @@ class PostImportPreviewer THUMBNAIL_FETCH_WARNING = 'サムネールを取得できませんでした.'.freeze METADATA_FETCH_WARNING = '自動取得に失敗しました.'.freeze - def preview_rows rows:, fetch_metadata: true, metadata_cache: { } + def preview_rows rows:, locale: Locale.nipponese, fetch_metadata: true, metadata_cache: { } prepared_rows = rows.map { prepare_row(_1) } url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally existing_posts = @@ -30,13 +30,14 @@ class PostImportPreviewer url_counts) known_tags = preload_known_tags( + locale, prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts) prepared_rows.map { |row| - preview_row(row, + preview_row(locale, row, fetch_metadata:, metadata_cache:, existing_posts:, @@ -59,7 +60,7 @@ class PostImportPreviewer source.merge(url_text: url, normal_url:, url_error: validate_url_safety(normal_url)) end - def preview_row row, + def preview_row locale, row, fetch_metadata:, metadata_cache:, existing_posts:, @@ -124,7 +125,8 @@ class PostImportPreviewer attributes['url'] = url validate_basic_data(attributes, validation_errors) - validate_preview_tags(merged_tags(tag_sources, provenance['tags']), + validate_preview_tags(locale, + merged_tags(tag_sources, provenance['tags']), validation_errors, known_tags) validate_parents(attributes['parent_post_ids'], validation_errors, existing_parent_ids) @@ -346,7 +348,7 @@ class PostImportPreviewer { data: { }, warnings: { 'url' => [METADATA_FETCH_WARNING] }, validation_errors: { } } end - def preload_known_tags prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts + def preload_known_tags locale, prepared_rows, fetch_metadata, metadata_cache, existing_posts, url_counts names = prepared_rows.flat_map { |row| attributes = initial_attributes(row) provenance = initial_provenance(row) @@ -358,15 +360,13 @@ class PostImportPreviewer metadata = metadata_for(row[:normal_url], metadata_cache) apply_metadata!(attributes, provenance, tag_sources, metadata[:data]) end - preview_tag_names(merged_tags(tag_sources, provenance['tags'])) + preview_tag_names(locale, merged_tags(tag_sources, provenance['tags'])) }.compact.uniq return { } if names.empty? - Tag.joins(:tag_name) - .where(tag_names: { name: names }) - .includes(:tag_name) - .to_a - .index_by(&:name) + TagName.where(language_code: locale.language_code, primary_flg: true, name: names) + .includes(:tag) + .each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag } end def preload_parent_ids prepared_rows @@ -437,19 +437,21 @@ class PostImportPreviewer sources['automatic'].to_s end - def preview_tag_names raw + def preview_tag_names locale, raw names = raw.to_s.split return [] if names.empty? if names.any? { _1.downcase.start_with?('nico:') } return [] end - names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first } + names.map { |name| + TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first + } rescue Tag::SectionLiteralParseError [] end - def validate_preview_tags raw, errors, known_tags + def validate_preview_tags locale, raw, errors, known_tags names = raw.to_s.split return if names.empty? if names.any? { _1.downcase.start_with?('nico:') } @@ -457,9 +459,11 @@ class PostImportPreviewer return end - parsed = names.map { |name| TagName.canonicalise(name.sub(/\[.*\]\z/, '')).first } + parsed = names.map { |name| + TagName.canonicalise(locale, name.sub(/\[.*\]\z/, '')).first + } existing = parsed.filter_map { known_tags[_1] } - deprecated = existing.select(&:deprecated?).map(&:name) + deprecated = existing.select(&:deprecated?).map { _1.name(locale.language_code) } errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? rescue Tag::SectionLiteralParseError errors[:tags] = ['タグ区間の記法が不正です.'] diff --git a/backend/app/services/post_metadata_fetcher.rb b/backend/app/services/post_metadata_fetcher.rb index a8dcb6d..c5e2eaf 100644 --- a/backend/app/services/post_metadata_fetcher.rb +++ b/backend/app/services/post_metadata_fetcher.rb @@ -47,9 +47,9 @@ class PostMetadataFetcher existing_tags = Tag - .joins(:tag_name) - .where(tag_names: { name: names }) - .index_by(&:name) + .joins(:tag_names) + .where(tag_names: { language_code: 'ja', primary_flg: true, name: names }) + .index_by { _1.name('ja') } names.map { |name| tag = existing_tags[name] diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index a786058..e14d497 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -1,5 +1,5 @@ class TagVersionRecorder < VersionRecorder - def self.record! tag:, event_type:, created_by_user: + def self.record! tag:, event_type:, created_by_user:, **_options new(tag:, event_type:, created_by_user:).record! end @@ -14,10 +14,29 @@ class TagVersionRecorder < VersionRecorder def record_key = :tag def snapshot_attributes - { name: @record.name, + names = snapshot_names + compatibility_names = names.fetch('ja') + + { name: compatibility_names.fetch('name'), category: @record.category, deprecated_at: @record.deprecated_at, - aliases: @record.snapshot_aliases.join(' '), + aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '), + names:, parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } end + + def snapshot_names + @record.tag_names.order(:language_code, :name).group_by(&:language_code) + .transform_values do |tag_names| + primary = tag_names.find(&:primary?) + aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name| + { 'name' => tag_name.name, 'script_code' => tag_name.script_code } + } + + { 'name' => primary.name, + 'script_code' => primary.script_code, + 'auto_generated' => primary.auto_generated, + 'aliases' => aliases } + end + end end diff --git a/backend/app/services/tag_versioning.rb b/backend/app/services/tag_versioning.rb index d874a3e..4990801 100644 --- a/backend/app/services/tag_versioning.rb +++ b/backend/app/services/tag_versioning.rb @@ -1,22 +1,22 @@ class TagVersioning - def self.record! tag, event_type:, created_by_user: - TagVersionRecorder.record!(tag:, event_type:, created_by_user:) + def self.record! tag, event_type:, created_by_user:, language_code: 'ja' + TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) end - def self.ensure_snapshot! tag, created_by_user: + def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja' return if tag.tag_versions.exists? - TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) + TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:) end - def self.record_tag_snapshot! tag, created_by_user: + def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja' event_type = tag.tag_versions.exists? ? :update : :create - record!(tag, event_type:, created_by_user:) + record!(tag, event_type:, created_by_user:, language_code:) end - def self.record_tag_snapshots! tags, created_by_user: + def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja' tags.each do |tag| - record_tag_snapshot!(tag, created_by_user:) + record_tag_snapshot!(tag, created_by_user:, language_code:) end end end diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb index 124c913..8d5ab32 100644 --- a/backend/app/services/theatre_post_selector.rb +++ b/backend/app/services/theatre_post_selector.rb @@ -41,7 +41,7 @@ class TheatrePostSelector def weighted_candidates @weighted_candidates ||= begin penalties = tag_penalties - posts = eligible_posts.includes(tags: :tag_name).to_a + posts = eligible_posts.includes(tags: :tag_names).to_a posts.map do |post| post_tags = post.tags.to_a @@ -82,7 +82,7 @@ class TheatrePostSelector def tag_penalty_json return [] if tag_penalties.empty? - tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id) + tags = Tag.where(id: tag_penalties.keys).includes(:tag_names).index_by(&:id) tag_penalties .map { |tag_id, penalty| @@ -113,7 +113,7 @@ class TheatrePostSelector def light_tag_json tag { id: tag.id, - name: tag.name, + name: tag.name('ja'), category: tag.category } end end diff --git a/backend/db/migrate/20260922000000_create_languages.rb b/backend/db/migrate/20260922000000_create_languages.rb new file mode 100644 index 0000000..e9f1887 --- /dev/null +++ b/backend/db/migrate/20260922000000_create_languages.rb @@ -0,0 +1,20 @@ +class CreateLanguages < ActiveRecord::Migration[8.0] + def up + create_table :languages, id: { type: :string, limit: 16 }, primary_key: :code do |t| + t.string :name, null: false + t.datetime :deprecated_at, index: true + t.datetime :created_at, null: false + end + + execute <<~SQL + INSERT INTO + languages(code, name, created_at) + VALUES + ('ja', '日本語', #{ connection.quote Time.current }) + SQL + end + + def down + drop_table :languages + end +end diff --git a/backend/db/migrate/20260922010000_create_scripts.rb b/backend/db/migrate/20260922010000_create_scripts.rb new file mode 100644 index 0000000..b825c84 --- /dev/null +++ b/backend/db/migrate/20260922010000_create_scripts.rb @@ -0,0 +1,23 @@ +class CreateScripts < ActiveRecord::Migration[8.0] + def up + create_table :scripts, id: { type: 'CHAR(4)' }, primary_key: :code do |t| + t.string :name, null: false + t.datetime :deprecated_at, index: true + t.datetime :created_at, null: false + + t.check_constraint "REGEXP_LIKE(code, '^[A-Z][a-z]{3}$', 'c')", + name: 'scripts_code_check' + end + + execute <<~SQL + INSERT INTO + scripts(code, name, created_at) + VALUES + ('Jpan', '漢字、仮名文字およびラテン文字', #{ connection.quote Time.current }) + SQL + end + + def down + drop_table :scripts + end +end diff --git a/backend/db/migrate/20260922013000_create_regions.rb b/backend/db/migrate/20260922013000_create_regions.rb new file mode 100644 index 0000000..c44c069 --- /dev/null +++ b/backend/db/migrate/20260922013000_create_regions.rb @@ -0,0 +1,25 @@ +class CreateRegions < ActiveRecord::Migration[8.0] + def up + create_table :regions, id: { type: :integer, limit: 2 }, primary_key: :code do |t| + t.column :alpha_code, 'CHAR(2)' + t.string :name, null: false + t.datetime :deprecated_at, index: true + t.datetime :created_at, null: false + + t.check_constraint "REGEXP_LIKE(alpha_code, '^[A-Z]{2}$', 'c')", + name: 'regions_alpha_code_check' + t.index [:alpha_code], unique: true + end + + execute <<~SQL + INSERT INTO + regions(code, alpha_code, name, created_at) + VALUES + (392, 'JP', '日本国', #{ connection.quote Time.current }) + SQL + end + + def down + drop_table :regions + end +end diff --git a/backend/db/migrate/20260922020000_create_locales.rb b/backend/db/migrate/20260922020000_create_locales.rb new file mode 100644 index 0000000..c396b69 --- /dev/null +++ b/backend/db/migrate/20260922020000_create_locales.rb @@ -0,0 +1,38 @@ +class CreateLocales < ActiveRecord::Migration[8.0] + def up + create_table :locales, id: { type: :string, limit: 32 }, primary_key: :code do |t| + t.string :language_code, limit: 16, null: false, index: true + t.column :script_code, 'CHAR(4)', null: false + t.integer :region_code, limit: 2, null: false + t.string :variants, limit: 255, null: false, default: '' + t.string :name, null: false + t.datetime :deprecated_at, index: true + t.datetime :created_at, null: false + + t.foreign_key :languages, column: :language_code, primary_key: :code + t.foreign_key :scripts, column: :script_code, primary_key: :code + t.foreign_key :regions, column: :region_code, primary_key: :code + t.check_constraint ('REGEXP_LIKE(variants, ' + + "'^(" + + '(([a-z0-9]{5,8}|[0-9][a-z0-9]{3})' + + '(-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*' + + '(-x(-[a-z0-9]{1,8})+)?)' + + '|(x(-[a-z0-9]{1,8})+)' + + ")?$', 'c')"), + name: 'locales_variants_check' + + t.index [:language_code, :script_code, :region_code, :variants], unique: true + end + + execute <<~SQL + INSERT INTO + locales(code, language_code, script_code, region_code, name, created_at) + VALUES + ('ja', 'ja', 'Jpan', 392, '日本語', #{ connection.quote Time.current }) + SQL + end + + def down + drop_table :locales + end +end diff --git a/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb b/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb new file mode 100644 index 0000000..0924d82 --- /dev/null +++ b/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb @@ -0,0 +1,69 @@ +class AddColumnsToTagNames < ActiveRecord::Migration[8.0] + def up + add_reference :tag_names, :tag, after: :id, foreign_key: true + add_column :tag_names, :language_code, :string, + limit: 16, null: false, after: :tag_id, default: 'ja' + add_column :tag_names, :script_code, 'CHAR(4)', + null: false, after: :name, default: 'Jpan' + add_column :tag_names, :primary_flg, :boolean, + null: false, after: :script_code, default: true + add_column :tag_names, :auto_generated, :boolean, + null: false, default: false, after: :primary_flg + + add_foreign_key :tag_names, :languages, column: :language_code, primary_key: :code + add_foreign_key :tag_names, :scripts, column: :script_code, primary_key: :code + + remove_index :tag_names, :name + add_index :tag_names, [:language_code, :name], unique: true + + change_column_default :tag_names, :language_code, from: 'ja', to: nil + change_column_default :tag_names, :script_code, from: 'Jpan', to: nil + + execute <<~SQL + UPDATE + tag_names tn + LEFT JOIN + tags AS t + ON + t.tag_name_id = COALESCE(tn.canonical_id, tn.id) + SET + tn.tag_id = t.id + , tn.primary_flg = CASE + WHEN tn.canonical_id IS NULL THEN + 1 + ELSE + 0 + END + SQL + + change_column_default :tag_names, :primary_flg, from: true, to: nil + + add_column :tag_names, :primary_tag_id, :bigint, + as: 'CASE WHEN primary_flg THEN tag_id ELSE NULL END' + add_index :tag_names, [:primary_tag_id, :language_code], unique: true + + add_check_constraint :tag_names, + 'primary_flg OR (NOT auto_generated)', + name: 'chk_tag_names_auto_generated_primary' + end + + def down + remove_check_constraint :tag_names, + name: 'chk_tag_names_auto_generated_primary' + + remove_index :tag_names, [:primary_tag_id, :language_code] + remove_column :tag_names, :primary_tag_id + + remove_foreign_key :tag_names, column: :language_code + remove_foreign_key :tag_names, column: :script_code + + remove_index :tag_names, [:language_code, :name] + add_index :tag_names, :name, unique: true + + remove_column :tag_names, :auto_generated + remove_column :tag_names, :primary_flg + remove_column :tag_names, :script_code + remove_column :tag_names, :language_code + remove_reference :tag_names, :tag, foreign_key: true + end +end diff --git a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb new file mode 100644 index 0000000..817b6d4 --- /dev/null +++ b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb @@ -0,0 +1,68 @@ +class AddNamesToTagVersions < ActiveRecord::Migration[8.0] + class TagVersion < ActiveRecord::Base + self.table_name = 'tag_versions' + end + + def up + add_column :tag_versions, :names, :json + TagVersion.reset_column_information + + say_with_time 'Backfilling multilingual tag version names' do + TagVersion.find_each do |version| + aliases = version.aliases.to_s.split.filter_map do |name| + name.present? && { name:, script_code: 'Jpan' } + end + names = { ja: { name: version.name, + script_code: 'Jpan', + auto_generated: false, + aliases: } } + version.update_columns(names:) + end + end + + add_names_constraint! + + change_column_null :tag_versions, :names, false + end + + def down + remove_check_constraint :tag_versions, + name: 'chk_tag_versions_names_schema' + + remove_column :tag_versions, :names + end + + private + + def add_names_constraint! + schema = { type: 'object', + minProperties: 1, + patternProperties: { '^.{1,16}$': name_schema }, + additionalProperties: false } + + quoted_schema = connection.quote(JSON.generate(schema)) + + add_check_constraint :tag_versions, + "JSON_SCHEMA_VALID(#{ quoted_schema }, names)", + name: 'chk_tag_versions_names_schema' + end + + def name_schema + { type: 'object', + required: ['name', 'script_code', 'auto_generated', 'aliases'], + properties: { **name_schema_base, + auto_generated: { type: 'boolean' }, + aliases: { type: 'array', + uniqueItems: true, + items: { type: 'object', + required: ['name', 'script_code'], + properties: name_schema_base, + additionalProperties: false } } }, + additionalProperties: false } + end + + def name_schema_base + { name: { type: 'string', minLength: 1, maxLength: 255 }, + script_code: { type: 'string', pattern: '^[A-Z][a-z]{3}$' } } + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index c4ce56a..eee4568 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do +ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -138,6 +138,29 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true end + create_table "languages", primary_key: "code", id: { type: :string, limit: 16 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_languages_on_deprecated_at" + end + + create_table "locales", primary_key: "code", id: { type: :string, limit: 32 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "language_code", limit: 16, null: false + t.string "script_code", limit: 4, null: false + t.integer "region_code", limit: 2, null: false + t.string "variants", default: "", null: false + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" + t.index ["language_code", "script_code", "region_code", "variants"], name: "idx_on_language_code_script_code_region_code_varian_b0ba85c39a", unique: true + t.index ["language_code"], name: "index_locales_on_language_code" + t.index ["region_code"], name: "fk_rails_175159992c" + t.index ["script_code"], name: "fk_rails_0b74ce96a8" + t.check_constraint "regexp_like(`variants`,_utf8mb4'^((([a-z0-9]{5,8}|[0-9][a-z0-9]{3})(-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(-x(-[a-z0-9]{1,8})+)?)|(x(-[a-z0-9]{1,8})+))?$',_utf8mb4'c')", name: "locales_variants_check" + end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -388,6 +411,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end + create_table "regions", primary_key: "code", id: { type: :integer, limit: 2 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "alpha_code", limit: 2 + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["alpha_code"], name: "index_regions_on_alpha_code", unique: true + t.index ["deprecated_at"], name: "index_regions_on_deprecated_at" + t.check_constraint "regexp_like(`alpha_code`,_utf8mb4'^[A-Z]{2}$',_utf8mb4'c')", name: "regions_alpha_code_check" + end + + create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" + t.check_constraint "regexp_like(`code`,_utf8mb4'^[A-Z][a-z]{3}$',_utf8mb4'c')", name: "scripts_code_check" + end + create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -420,12 +461,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do end create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id" + t.string "language_code", limit: 16, null: false t.string "name", null: false + t.string "script_code", limit: 4, null: false + t.boolean "primary_flg", null: false + t.boolean "auto_generated", default: false, null: false t.bigint "canonical_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" - t.index ["name"], name: "index_tag_names_on_name", unique: true + t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true + t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true + t.index ["script_code"], name: "fk_rails_dd783b3d1c" + t.index ["tag_id"], name: "index_tag_names_on_tag_id" + t.check_constraint "(0 <> `primary_flg`) or (0 = `auto_generated`)", name: "chk_tag_names_auto_generated_primary" end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -447,11 +498,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do t.datetime "deprecated_at" t.datetime "created_at", null: false t.bigint "created_by_user_id" + t.json "names", null: false t.index ["created_at"], name: "index_tag_versions_on_created_at" t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" + t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"object\",\"minProperties\":1,\"patternProperties\":{\"^.{1,16}$\":{\"type\":\"object\",\"required\":[\"name\",\"script_code\",\"auto_generated\",\"aliases\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":255},\"script_code\":{\"type\":\"string\",\"pattern\":\"^[A-Z][a-z]{3}$\"},\"auto_generated\":{\"type\":\"boolean\"},\"aliases\":{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"type\":\"object\",\"required\":[\"name\",\"script_code\"],\"properties\":{\"name\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":255},\"script_code\":{\"type\":\"string\",\"pattern\":\"^[A-Z][a-z]{3}$\"}},\"additionalProperties\":false}}},\"additionalProperties\":false}},\"additionalProperties\":false}',`names`)", name: "chk_tag_versions_names_schema" end create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -701,6 +754,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "users", column: "created_by_id" + add_foreign_key "locales", "languages", column: "language_code", primary_key: "code" + add_foreign_key "locales", "regions", column: "region_code", primary_key: "code" + add_foreign_key "locales", "scripts", column: "script_code", primary_key: "code" add_foreign_key "material_export_items", "materials" add_foreign_key "material_export_items", "users", column: "created_by_user_id" add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" @@ -734,7 +790,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do add_foreign_key "settings", "users" add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags", column: "parent_tag_id" + add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" + add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code" add_foreign_key "tag_names", "tag_names", column: "canonical_id" + add_foreign_key "tag_names", "tags" add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id" diff --git a/backend/spec/db/delete_discarded_tags_spec.rb b/backend/spec/db/delete_discarded_tags_spec.rb index 3882e0d..6d6a533 100644 --- a/backend/spec/db/delete_discarded_tags_spec.rb +++ b/backend/spec/db/delete_discarded_tags_spec.rb @@ -11,6 +11,7 @@ RSpec.describe 'discarded tag cleanup migrations' do end context 'with legacy records' do + # Reproduce the historical schema required by these pre-multilingual migrations. self.use_transactional_tests = false before do diff --git a/backend/spec/db/tag_name_constraints_spec.rb b/backend/spec/db/tag_name_constraints_spec.rb new file mode 100644 index 0000000..02ba449 --- /dev/null +++ b/backend/spec/db/tag_name_constraints_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe 'TagName database invariants' do + def insert_name!(**attributes) + TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja', + script_code: 'Jpan', primary_flg: true, + created_at: Time.current, updated_at: Time.current, + **attributes }]) + end + + it 'defaults persisted names to human-confirmed provenance' do + insert_name! + + expect(TagName.find_by!(name: 'constraint_name').auto_generated).to be(false) + end + + [[true, true], [true, false], [false, false]].each do |primary_flg, auto_generated| + it "allows primary=#{ primary_flg }, auto_generated=#{ auto_generated }" do + expect { insert_name!(primary_flg:, auto_generated:) } + .to change(TagName, :count).by(1) + end + end + + it 'rejects auto-generated aliases at the database boundary' do + expect { insert_name!(primary_flg: false, auto_generated: true) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + + it 'rejects a second primary for the same tag and language' do + tag = create(:tag) + + expect { insert_name!(tag_id: tag.id, auto_generated: false) } + .to raise_error(ActiveRecord::RecordNotUnique) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + end +end diff --git a/backend/spec/db/tag_version_names_spec.rb b/backend/spec/db/tag_version_names_spec.rb new file mode 100644 index 0000000..3c351b4 --- /dev/null +++ b/backend/spec/db/tag_version_names_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +RSpec.describe 'TagVersion names database constraint' do + let(:tag) { create(:tag) } + let(:names) do + { 'ja' => { 'name' => 'snapshot_name', 'script_code' => 'Jpan', + 'auto_generated' => false, 'aliases' => [] } } + end + let(:alias_name) { { 'name' => 'snapshot_alias', 'script_code' => 'Jpan' } } + + def insert_snapshot!(snapshot) + TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create', + name: 'snapshot_name', category: 'general', + aliases: '', parent_tag_ids: '', names: snapshot, + created_at: Time.current }]) + end + + it 'accepts grouped Nipponese names with an empty alias list' do + expect { insert_snapshot!(names) }.to change(TagVersion, :count).by(1) + expect(TagVersion.find_by!(tag_id: tag.id).names).to eq(names) + end + + it 'accepts aliases containing only their name and script' do + names['ja']['aliases'] = [alias_name] + + expect { insert_snapshot!(names) }.to change(TagVersion, :count).by(1) + end + + it 'rejects a flat array root' do + expect { insert_snapshot!([names.fetch('ja')]) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + + it 'rejects an empty object root' do + expect { insert_snapshot!({ }) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + + it 'rejects a language entry that is not an object' do + expect { insert_snapshot!({ 'ja' => 'not an object' }) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + + ['name', 'script_code', 'auto_generated', 'aliases'].each do |field| + it "rejects a primary entry missing #{ field }" do + names['ja'].delete(field) + + expect { insert_snapshot!(names) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + end + + it 'rejects an empty primary name' do + names['ja']['name'] = '' + + expect { insert_snapshot!(names) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + + it 'rejects incorrect script casing' do + names['ja']['script_code'] = 'JPAN' + + expect { insert_snapshot!(names) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + + ['unknown', 'primary_flg', 'language_code'].each do |field| + it "rejects the extra primary field #{ field }" do + names['ja'][field] = true + + expect { insert_snapshot!(names) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + end + + ['unknown', 'primary_flg', 'auto_generated'].each do |field| + it "rejects the extra alias field #{ field }" do + names['ja']['aliases'] = [alias_name.merge(field => true)] + + expect { insert_snapshot!(names) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + end + + it 'rejects duplicate alias objects' do + names['ja']['aliases'] = [alias_name, alias_name.deep_dup] + + expect { insert_snapshot!(names) } + .to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end +end diff --git a/backend/spec/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index a33be1e..0e98e3b 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -1,5 +1,13 @@ FactoryBot.define do factory :tag_name do - name { "tag-#{SecureRandom.hex(4)}" } + language_code { 'ja' } + script_code { 'Jpan' } + primary_flg { true } + auto_generated { false } + sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" } + + trait :alias do + primary_flg { false } + end end end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 55db316..d4ac59b 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -1,15 +1,41 @@ FactoryBot.define do factory :tag do transient do - name { nil } + primary_name { nil } + primary_tag_name do + attributes = primary_name.nil? ? { } : { name: primary_name } + build(:tag_name, **attributes) + end end category { :general } post_count { 0 } - association :tag_name after(:build) do |tag, evaluator| - tag.name = evaluator.name if evaluator.name.present? + evaluator.primary_tag_name.primary_flg = true + tag.tag_names.target << evaluator.primary_tag_name + end + + before(:create) do |tag, evaluator| + evaluator.primary_tag_name.save! + # Compatibility write only; ownership is established through tag_id below. + tag.tag_name_id = evaluator.primary_tag_name.id + end + + after(:create) do |tag, evaluator| + evaluator.primary_tag_name.update!(tag:, primary_flg: true) + + Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a + .uniq(&:language_code).each do |locale| + next if tag.tag_names.exists?(language_code: locale.language_code, + primary_flg: true) + + create(:tag_name, + tag:, language_code: locale.language_code, + script_code: locale.script_code, + name: TagName.generate_name(locale, tag, evaluator.primary_tag_name), + primary_flg: true, auto_generated: true) + end end end end diff --git a/backend/spec/models/language_spec.rb b/backend/spec/models/language_spec.rb new file mode 100644 index 0000000..9a2edcb --- /dev/null +++ b/backend/spec/models/language_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe Language, type: :model do + it 'accepts Nipponese as the current supported language' do + expect(described_class.find('ja')).to be_valid + expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja']) + end + + it 'accepts English when multilingual support is enabled' do + pending '日本語以外の Language 有効化は別課題で実装する' + expect { described_class.create!(code: 'en', name: 'English') } + .to change(described_class, :count).by(1) + end + + it 'rejects an unsupported language code' do + expect { + described_class.create!(code: 'xx', name: 'Unsupported') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end + + ['en', 'fr'].each do |code| + it "does not register #{ code } as a currently supported language" do + expect { + described_class.create!(code:, name: 'Unsupported') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end + end +end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb new file mode 100644 index 0000000..62fbd55 --- /dev/null +++ b/backend/spec/models/locale_spec.rb @@ -0,0 +1,129 @@ +require 'rails_helper' + +RSpec.describe Locale, type: :model do + include_context 'English locale' + + def create_english_locale! + prepare_english_reference! + described_class.create!(code: 'en', language_code: 'en', + script_code: 'Latn', region_code: 826, + variants: '', name: 'English') + end + + it 'keeps the active Nipponese locale consistent with its identity tuple' do + expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja']) + expect(described_class.nipponese).to have_attributes( + code: 'ja', language_code: 'ja', script_code: 'Jpan', + region_code: 392, variants: '') + end + + it 'rejects a duplicate locale identity tuple at the database boundary' do + expect { + described_class.insert_all!([{ code: 'ja-duplicate', language_code: 'ja', + script_code: 'Jpan', region_code: 392, + variants: '', name: '日本語', + created_at: Time.current }]) + }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'generates a primary name for every existing tag using the name generator' do + pending '日本語以外の Locale 有効化は別課題で実装する' + first = create(:tag, primary_name: 'first_existing_tag') + second = create(:tag, primary_name: 'second_existing_tag') + allow(TagName).to receive(:generate_name).and_call_original + allow(TagName).to receive(:generate_name) + .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja')) + .and_return('name_from_generator') + + locale = create_english_locale! + + expect(TagName).to have_received(:generate_name) + .with(locale, first, primary_tag_name_for(first, 'ja')) + expect(TagName).to have_received(:generate_name) + .with(locale, second, primary_tag_name_for(second, 'ja')) + expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true)) + .to have_attributes(name: 'name_from_generator', tag_id: first.id, + language_code: 'en', script_code: 'Latn', + primary_flg: true, auto_generated: true) + expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true)) + .to have_attributes(tag_id: second.id, language_code: 'en', + script_code: 'Latn', primary_flg: true, + auto_generated: true) + expect(TagName.where(language_code: 'en', primary_flg: true, + tag_id: [first.id, second.id]).count).to eq(2) + end + + it 'does not duplicate an existing primary name in the new language' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_reference! + tag = create(:tag, primary_name: 'already_named') + existing = create(:tag_name, name: 'existing_english', tag:, + language_code: 'en', script_code: 'Latn') + + create_english_locale! + + expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true)) + .to contain_exactly(existing) + expect(existing.reload).to have_attributes( + tag_id: tag.id, language_code: 'en', script_code: 'Latn', + primary_flg: true, auto_generated: false) + end + + it 'creates a primary name when the language has only an alias' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_reference! + tag = create(:tag, primary_name: 'alias_only_tag') + alias_name = create(:tag_name, :alias, name: 'alias_en', + tag:, + language_code: 'en', script_code: 'Latn') + + create_english_locale! + + expect(alias_name.reload).to have_attributes( + tag_id: tag.id, + language_code: 'en', script_code: 'Latn', primary_flg: false) + generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) + expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en', + script_code: 'Latn', primary_flg: true, + auto_generated: true) + expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1) + end + + it 'rolls back the locale and all generated names when backfill fails' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_reference! + first = create(:tag, primary_name: 'atomic_first') + second = create(:tag, primary_name: 'atomic_second') + allow(TagName).to receive(:generate_name).and_call_original + allow(TagName).to receive(:generate_name) + .with(kind_of(described_class), second, primary_tag_name_for(second, 'ja')) + .and_raise('backfill failed') + + expect { create_english_locale! }.to raise_error('backfill failed') + + expect(described_class.exists?(code: 'en')).to be(false) + expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty + end + + it 'accepts a stable locale code outside the historical enum values' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_reference! + + locale = described_class.create!( + code: 'en-x-rspec', language_code: 'en', + script_code: 'Latn', region_code: 826, + variants: 'x-rspec', name: 'English') + + expect(locale.reload).to have_attributes( + code: 'en-x-rspec', language_code: 'en', script_code: 'Latn', + region_code: 826, variants: 'x-rspec') + end + + it 'registers the future British English locale tuple' do + pending '日本語以外の Locale 有効化は別課題で実装する' + + expect(locale).to have_attributes(code: 'en', language_code: 'en', + script_code: 'Latn', region_code: 826, + variants: '') + end +end diff --git a/backend/spec/models/material_export_item_spec.rb b/backend/spec/models/material_export_item_spec.rb index d98e225..19734a0 100644 --- a/backend/spec/models/material_export_item_spec.rb +++ b/backend/spec/models/material_export_item_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe MaterialExportItem, type: :model do let(:user) { create(:user, :member) } - let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) } + let(:tag) { create(:tag, primary_name: 'export_item', category: :material) } let(:material) do Material.create!(tag:, url: 'https://example.com/material', created_by_user: user, updated_by_user: user) diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb index ea5646e..f12d90b 100644 --- a/backend/spec/models/post_spec.rb +++ b/backend/spec/models/post_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Post, type: :model do expect(post.snapshot_tags_json).to eq([ { 'tag_id' => tag.id, 'version_no' => tag.version_no, - 'name' => tag.name, + 'name' => primary_tag_name_for(tag, 'ja').name, 'category' => tag.category, 'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] }, { 'external_tag_id' => external.id }]) diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb index 4cc0e82..f29b530 100644 --- a/backend/spec/models/post_version_spec.rb +++ b/backend/spec/models/post_version_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' RSpec.describe PostVersion, type: :model do - let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } - let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } + let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') } + let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) } let!(:post_record) do Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| diff --git a/backend/spec/models/script_spec.rb b/backend/spec/models/script_spec.rb new file mode 100644 index 0000000..20365c1 --- /dev/null +++ b/backend/spec/models/script_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe Script, type: :model do + include_context 'English locale' + + it 'selects locales by script code, not language code' do + expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese) + end + + it 'accepts supported script codes and rejects unsupported ones' do + expect(described_class.find('Jpan')).to be_valid + expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['Jpan']) + expect { + described_class.create!(code: 'Zzzz', name: 'Unsupported') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end + + it 'does not register Latin as a currently supported script' do + expect { + described_class.create!(code: 'Latn', name: 'Latin') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end + + it 'selects Latin locales when multilingual support is enabled' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + + expect(described_class.find('Latn').locales).to contain_exactly(locale) + expect(described_class.find('Jpan').locales).not_to include(locale) + end +end diff --git a/backend/spec/models/tag_implication_spec.rb b/backend/spec/models/tag_implication_spec.rb index a29cc1c..9034d2e 100644 --- a/backend/spec/models/tag_implication_spec.rb +++ b/backend/spec/models/tag_implication_spec.rb @@ -2,8 +2,8 @@ require 'rails_helper' RSpec.describe TagImplication, type: :model do it 'rejects a parent tag that would create a cycle' do - child = create(:tag, name: 'tag_implication_cycle_child') - parent = create(:tag, name: 'tag_implication_cycle_parent') + child = create(:tag, primary_name: 'tag_implication_cycle_child') + parent = create(:tag, primary_name: 'tag_implication_cycle_parent') described_class.create!(tag: child, parent_tag: parent) @@ -17,9 +17,9 @@ RSpec.describe TagImplication, type: :model do end it 'terminates even when existing data already contains a cycle' do - child = create(:tag, name: 'tag_implication_existing_cycle_child') - parent = create(:tag, name: 'tag_implication_existing_cycle_parent') - ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor') + child = create(:tag, primary_name: 'tag_implication_existing_cycle_child') + parent = create(:tag, primary_name: 'tag_implication_existing_cycle_parent') + ancestor = create(:tag, primary_name: 'tag_implication_existing_cycle_ancestor') described_class.create!(tag: parent, parent_tag: ancestor) described_class.insert_all!( diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index b82f9fa..9cebc51 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -31,12 +31,48 @@ RSpec.describe TagNameSanitisationRule, type: :model do describe '.apply!' do before do - described_class.create!(priority: 10, source_pattern: '_', replacement: '') + described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo') + end + + context 'when only another language has the sanitised name' do + include_context 'English locale' + + it 'keeps both names when sanitisation produces a cross-language match' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = create(:tag_name, name: 'foobar') + english = create(:tag_name, name: 'temporary', + language_code: 'en', script_code: 'Latn') + english.update_columns(name: 'foo_bar') + + expect { described_class.apply! }.not_to change(TagName, :count) + + expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en') + expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja') + end + + it 'renames without merging tags or deleting either language identity' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = create(:tag, primary_name: 'foobar') + english_name = create(:tag_name, name: 'temporary', + language_code: 'en', script_code: 'Latn') + english = create(:tag, primary_tag_name: english_name) + english_name.update_columns(name: 'foo_bar') + + described_class.apply! + + expect(english_name.reload).to have_attributes( + name: 'foobar', language_code: 'en', tag_id: english.id) + expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes( + name: 'foobar', language_code: 'ja', tag_id: nipponese.id) + expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name) + end end context 'when no conflicting tag_name exists' do let!(:tag_name) do - TagName.create!(name: 'tmp').tap do |tn| + create(:tag_name, name: 'tmp').tap do |tn| tn.update_columns(name: 'foo_bar', updated_at: Time.current) end end @@ -47,10 +83,10 @@ RSpec.describe TagNameSanitisationRule, type: :model do end end - context 'when a conflicting canonical tag_name exists' do - let!(:existing) { TagName.create!(name: 'foobar') } + context 'when a conflicting primary tag name exists' do + let!(:existing) { create(:tag_name, name: 'foobar') } let!(:source) do - TagName.create!(name: 'tmp').tap do |tn| + create(:tag_name, name: 'tmp').tap do |tn| tn.update_columns(name: 'foo_bar', updated_at: Time.current) end end @@ -63,48 +99,56 @@ RSpec.describe TagNameSanitisationRule, type: :model do end context 'when the source tag_name has a tag and the existing one has no tag' do - let!(:existing) { TagName.create!(name: 'foobar') } - let!(:source_tag) { create(:tag, name: 'tmp', category: :general) } - let!(:source_tag_name_id) { source_tag.tag_name_id } + let!(:existing) { create(:tag_name, name: 'foobar') } + let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) } + let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id } before do - source_tag.tag_name.update_columns(name: 'foo_bar', updated_at: Time.current) + primary_tag_name_for(source_tag, 'ja').update_columns( + name: 'foo_bar', updated_at: Time.current) end it 'moves the tag to the existing tag_name' do described_class.apply! - expected_tag_name_id = existing.canonical_id || existing.id - expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id) + expect(existing.reload).to have_attributes( + tag_id: source_tag.id, language_code: 'ja', primary_flg: true) + expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing) + expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) end end context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } + let!(:existing_primary) { primary_tag_name_for(existing_tag, 'ja') } let!(:alias_name) do - TagName.create!(name: 'foobar', canonical: existing_tag.tag_name) + create(:tag_name, :alias, name: 'foobar', tag: existing_tag) end let!(:source) do - TagName.create!(name: 'tmp').tap do |tn| + create(:tag_name, name: 'tmp').tap do |tn| tn.update_columns(name: 'foo_bar', updated_at: Time.current) end end - it 'deletes only the source and preserves the alias and its canonical tag' do + 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.canonical).to eq(existing_tag.tag_name) + expect(alias_name.reload).to have_attributes( + tag_id: existing_tag.id, language_code: 'ja', primary_flg: false) + expect(TagName.find_by!(tag_id: existing_tag.id, + language_code: 'ja', primary_flg: true)) + .to eq(existing_primary) expect(Tag.find(existing_tag.id)).to eq(existing_tag) end end context 'when both source and existing tag_names have tags' do - let!(:existing_tn) { TagName.create!(name: 'foobar') } - let!(:existing_tag) { Tag.create!(tag_name: existing_tn, category: :general) } + let!(:existing_tn) { create(:tag_name, name: 'foobar') } + let!(:existing_tag) { create(:tag, primary_tag_name: existing_tn, category: :general) } - let!(:source_tn) { TagName.create!(name: 'tmp') } - let!(:source_tag) { Tag.create!(tag_name: source_tn, category: :general) } + let!(:source_tn) { create(:tag_name, name: 'tmp') } + let!(:source_tag) { create(:tag, primary_tag_name: source_tn, category: :general) } let!(:source_tag_name_id) { source_tn.id } before do @@ -120,7 +164,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) expect(post.reload.tags).to contain_exactly(existing_tag) - expect(existing_tag.reload.name).to eq('foobar') + expect(primary_tag_name_for(existing_tag.reload, 'ja').name).to eq('foobar') end end end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb new file mode 100644 index 0000000..8239d65 --- /dev/null +++ b/backend/spec/models/tag_name_spec.rb @@ -0,0 +1,171 @@ +require 'rails_helper' + +RSpec.describe TagName, type: :model do + include_context 'English locale' + + it 'allows the same name in different languages' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + create(:tag_name, name: 'shared_name') + english = build(:tag_name, name: 'shared_name', + language_code: 'en', script_code: 'Latn') + + expect(english).to be_valid + expect { english.save! }.to change(described_class, :count).by(1) + end + + it 'rejects a duplicate name within the same language' do + create(:tag_name, name: 'shared_name') + duplicate = build(:tag_name, name: 'shared_name') + + expect(duplicate).to be_invalid + expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) + end + + it 'creates Nipponese factory names without requiring a tag' do + name = create(:tag_name) + + expect(name.reload).to have_attributes( + language_code: 'ja', script_code: 'Jpan', primary_flg: true, + auto_generated: false, tag_id: nil) + end + + it 'defaults ordinary persisted human names to confirmed provenance' do + name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja', + script_code: 'Jpan', primary_flg: true) + + expect(name.reload.auto_generated).to be(false) + end + + it 'persists both sides of the representative tag factory association' do + name = create(:tag_name) + tag = create(:tag, primary_tag_name: name) + + expect(name.reload).to have_attributes( + tag_id: tag.id, language_code: 'ja', script_code: 'Jpan', primary_flg: true) + expect(name.tag).to eq(tag) + expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true)) + .to eq(name) + expect(tag.reload.tag_name_id).to eq(name.id) + end + + it 'creates a complete ordinary tag factory under the active Nipponese language' do + tag = create(:tag, primary_name: 'factory_multilingual') + + Locale.where(deprecated_at: nil).distinct.pluck(:language_code).each do |language_code| + expect(TagName.where(tag:, language_code:, primary_flg: true).count).to eq(1) + end + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(have_attributes(auto_generated: false)) + end + + it 'completes an ordinary tag factory under future active languages' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + tag = create(:tag, primary_name: 'future_factory') + + expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + .to contain_exactly(have_attributes(auto_generated: true)) + end + + it 'builds a primary name without persisting the tag or its name' do + tag = nil + + expect { + tag = build(:tag, primary_name: 'built_primary') + }.to change(Tag, :count).by(0).and change(described_class, :count).by(0) + + expect(tag).to be_new_record + expect(tag.tag_names.target).to contain_exactly(have_attributes( + name: 'built_primary', language_code: 'ja', script_code: 'Jpan', + primary_flg: true)) + end + + it 'persists an alias owned by the same tag as its primary name' do + primary_name = create(:tag_name) + tag = create(:tag, primary_tag_name: primary_name) + alias_name = create(:tag_name, :alias, name: 'valid_alias', tag:) + + expect(alias_name.reload).to have_attributes( + tag_id: tag.id, + primary_flg: false, language_code: 'ja') + primary = described_class.find_by!(tag_id: alias_name.tag_id, + language_code: alias_name.language_code, + primary_flg: true) + expect(primary).to eq(primary_name) + end + + describe '.canonicalise' do + it 'leaves detached aliases unchanged' do + create(:tag_name, :alias, name: 'detached_alias') + + expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) + .to eq(['detached_alias']) + end + + it 'does not resolve a detached alias through another detached primary' do + create(:tag_name, :alias, name: 'detached_alias') + create(:tag_name, name: 'unrelated_detached_primary') + + expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) + .to eq(['detached_alias']) + end + + it 'resolves the primary name of the alias language on the same tag' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + tag = create(:tag, primary_name: '日本語正本') + english_primary = primary_tag_name_for(tag, 'en') + english_primary.update!(name: 'english_primary', auto_generated: false) + english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', + language_code: 'en', script_code: 'Latn') + representative_id = tag.tag_name_id + + primary = described_class.find_by!(tag_id: english_alias.tag_id, + language_code: english_alias.language_code, + primary_flg: true) + + expect(primary).to eq(english_primary) + expect(described_class.canonicalise(locale, [english_alias.name])) + .to eq([english_primary.name]) + expect(tag.reload.tag_name_id).to eq(representative_id) + end + + it 'resolves only aliases in the requested language' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = create(:tag, primary_name: 'nipponese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, primary_tag_name: english_name) + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, + language_code: 'en', script_code: 'Latn') + + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + tag_id: english.id, + primary_flg: false, language_code: 'en') + expect(described_class.canonicalise(locale, ['shared_alias'])) + .to eq(['english_canonical']) + expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) + .to eq(['nipponese_canonical']) + end + end + + it 'rejects the generated-name prefix in human input' do + human_name = build(:tag_name, name: 'Tag_#999', auto_generated: false) + + expect(human_name).to be_invalid + end + + it 'allows the generated-name prefix for system-created names' do + generated_name = build(:tag_name, name: 'Tag_#1000', auto_generated: true) + + expect { generated_name.save! }.to change(described_class, :count).by(1) + end +end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index d63be45..1d08b42 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -4,12 +4,12 @@ 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, name:) + tag = build(:tag, primary_name: name) expect(tag).to be_invalid expect(tag.errors[:name]).to be_present expect { - described_class.normalise_tags!([name]) + described_class.normalise_tags!(Locale.nipponese, [name]) }.to raise_error(Tag::NicoTagNormalisationError) end end @@ -25,128 +25,161 @@ RSpec.describe Tag, type: :model do end describe '.normalise_tags!' do + include_context 'English locale' + + it 'canonicalises aliases in the supplied language only' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_reference! + nipponese = create(:tag, primary_name: 'nipponese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, primary_tag_name: english_name) + primary_tag_name_for(english, 'ja').update!( + name: 'english_in_nipponese', auto_generated: false) + english_locale = locale + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, + language_code: 'en', script_code: 'Latn') + + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + tag_id: english.id, + primary_flg: false, language_code: 'en') + expect(described_class.normalise_tags!( + english_locale, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([english]) + expect(described_class.normalise_tags!( + Locale.nipponese, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([nipponese]) + end + it 'rejects deprecated tags when deny_deprecated is enabled' do - tag_name = TagName.create!(name: 'normalise deprecated tag') - deprecated_tag = Tag.create!( - tag_name:, - category: :general, - deprecated_at: 1.day.from_now - ) + tag_name = create(:tag_name, name: 'normalise deprecated tag') + deprecated_tag = create(:tag, + primary_tag_name: tag_name, + category: :general, + deprecated_at: 1.day.from_now) expect { - described_class.normalise_tags!( - [deprecated_tag.name], - deny_deprecated: true - ) + described_class.normalise_tags!(Locale.nipponese, + [primary_tag_name_for(deprecated_tag, 'ja').name], + deny_deprecated: true) }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error| - expect(error.tag_names).to eq([deprecated_tag.name]) + expect(error.tag_names).to eq([primary_tag_name_for(deprecated_tag, 'ja').name]) } end it 'rejects invalid section literals instead of treating them as zero' do expect { - described_class.normalise_tags!( - ['normalise_invalid_section[1:aa-2:00]'], - with_sections: true - ) + described_class.normalise_tags!(Locale.nipponese, + ['normalise_invalid_section[1:aa-2:00]'], + with_sections: true) }.to raise_error(Tag::SectionLiteralParseError) end it 'parses open-ended section literals' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) end it 'parses omitted begin as zero' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[-1:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[-1:00]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) end it 'treats fully open section literals as plain tags' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[-]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections)[tag.id]).to be_nil end it 'treats [0:00-] as a plain tag' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[0:00-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[0:00-]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections)[tag.id]).to be_nil end it 'expands zero-width sections to one millisecond' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-1:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-1:00]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]]) end it 'swaps reversed section boundaries' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[2:00-1:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[2:00-1:00]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]]) end it 'merges open-ended sections over later bounded sections' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-][2:00-3:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-][2:00-3:00]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) end it 'merges adjacent bounded and open-ended sections' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-3:00][3:00-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-3:00][3:00-]'], + with_sections: true) - tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } + tag = result.fetch(:tags).find do |candidate| + primary_tag_name_for(candidate, 'ja').name == '伊地知ニジカ' + end expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) end end describe '.expand_parent_tags' do it 'expands through multiple deprecated parents to an active ancestor' do - child = create(:tag, name: 'expand_child') - deprecated_parent = create( - :tag, - name: 'expand_deprecated_parent', - deprecated_at: Time.current - ) - deprecated_grandparent = create( - :tag, - name: 'expand_deprecated_grandparent', - deprecated_at: Time.current - ) - active_ancestor = create(:tag, name: 'expand_active_ancestor') + child = create(:tag, primary_name: 'expand_child') + deprecated_parent = create(:tag, + primary_name: 'expand_deprecated_parent', + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + primary_name: 'expand_deprecated_grandparent', + deprecated_at: Time.current) + active_ancestor = create(:tag, primary_name: 'expand_active_ancestor') TagImplication.create!(tag: child, parent_tag: deprecated_parent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) @@ -163,8 +196,8 @@ RSpec.describe Tag, type: :model do end it 'terminates when implications contain a cycle' do - first = create(:tag, name: 'expand_cycle_first') - second = create(:tag, name: 'expand_cycle_second') + first = create(:tag, primary_name: 'expand_cycle_first') + second = create(:tag, primary_name: 'expand_cycle_second') TagImplication.create!(tag: first, parent_tag: second) now = Time.current TagImplication.insert_all!( @@ -183,55 +216,268 @@ RSpec.describe Tag, type: :model do end describe '.find_or_create_by_tag_name!' do + context 'with an explicit locale' do + include_context 'English locale' + + it 'creates the representative name with locale attributes and ownership' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + tag = described_class.find_or_create_by_tag_name!( + locale, 'english_name', category: :character) + + expect(primary_tag_name_for(tag, 'en')).to have_attributes( + language_code: 'en', script_code: 'Latn', + primary_flg: true, tag_id: tag.id) + expect(tag.category).to eq('character') + end + + it 'uses the English primary as the source for a generated Nipponese name' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + allow(TagName).to receive(:generate_name).and_return('generated_nipponese') + + tag = described_class.find_or_create_by_tag_name!( + locale, 'english_origin', category: :general) + + expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + .to contain_exactly(have_attributes( + name: 'english_origin', auto_generated: false)) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(have_attributes( + name: 'generated_nipponese', auto_generated: true)) + source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true) + expect(TagName).to have_received(:generate_name) + .with(Locale.nipponese, tag, source_tag_name) + end + + it 'gives a new tag one primary name for every active language' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + tag = described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'new_multilingual_tag', category: :general) + + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) + end + + it 'creates one primary per language when multiple locales use English' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + described_class.find_or_create_by_tag_name!( + locale, 'before_second_english_locale', category: :general) + Locale.create!(code: 'en-x-rspec', language_code: 'en', + script_code: 'Latn', region_code: 826, + variants: 'x-rspec', name: 'English') + + tag = described_class.find_or_create_by_tag_name!( + locale, 'one_english_primary', category: :general) + + expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + end + + it 'rolls back a new tag and requested name if name generation fails' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + original_tag_count = described_class.count + allow(TagName).to receive(:generate_name).and_raise('generation failed') + + expect { + described_class.find_or_create_by_tag_name!( + locale, 'atomic_english_name', category: :general) + }.to raise_error('generation failed') + + expect(described_class.count).to eq(original_tag_count) + expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty + expect(described_class.joins(:tag_names) + .where(tag_names: { name: 'atomic_english_name' })).to be_empty + end + + it 'keeps a detached requested name detached if generation fails' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + detached = create(:tag_name, :alias, name: 'atomic_detached', + language_code: 'en', script_code: 'Latn') + original_tag_count = described_class.count + allow(TagName).to receive(:generate_name).and_raise('generation failed') + + expect { + described_class.find_or_create_by_tag_name!( + locale, detached.name, category: :general) + }.to raise_error('generation failed') + + expect(described_class.count).to eq(original_tag_count) + expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false) + end + + it 'promotes a detached alias into the requested primary name' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + detached = create(:tag_name, :alias, name: 'detached_english', + language_code: 'en', script_code: 'Latn') + + tag = described_class.find_or_create_by_tag_name!( + locale, detached.name, category: :general) + + expect(detached.reload).to have_attributes( + tag_id: tag.id, primary_flg: true, canonical_id: nil, + auto_generated: false) + expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + .to contain_exactly(detached) + end + + it 'creates separate tag identities for the same name in different languages' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'same_name', category: :general) + english = described_class.find_or_create_by_tag_name!( + locale, 'same_name', category: :character) + + expect(english).not_to eq(nipponese) + expect(primary_tag_name_for(english, 'en')).to have_attributes( + name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true) + expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes( + name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true) + expect(described_class.find_or_create_by_tag_name!( + locale, 'same_name', category: :general)).to eq(english) + expect(described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'same_name', category: :general)).to eq(nipponese) + end + + it 'preserves the V1 representative when looking up another primary language' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + tag = create(:tag, primary_name: '日本語代表名') + representative_id = tag.tag_name_id + english = primary_tag_name_for(tag, 'en') + english.update!(name: 'english_primary', auto_generated: false) + + found = described_class.find_or_create_by_tag_name!( + locale, english.name, category: :general) + + expect(found).to eq(tag) + expect(tag.reload.tag_name_id).to eq(representative_id) + expect(english.reload.tag_id).to eq(tag.id) + end + + it 'normalises names using the supplied locale' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + tags = described_class.normalise_tags!( + locale, ['character:normalised_english'], + with_tagme: false, with_no_deerjikist: false) + + expect(tags.length).to eq(1) + expect(primary_tag_name_for(tags.first, 'en')).to have_attributes( + name: 'normalised_english', language_code: 'en', script_code: 'Latn', + primary_flg: true, tag_id: tags.first.id) + end + end + it 'creates a tag and name with the requested category after stripping whitespace' do tag = nil expect { - tag = described_class.find_or_create_by_tag_name!( + 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(tag.name).to eq('lookup_new') + expect(primary_tag_name_for(tag, 'ja').name).to eq('lookup_new') expect(tag.category).to eq('character') + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false) end - it 'reuses the canonical tag for an alias without changing its category' do + it 'promotes a detached Nipponese alias into a human-confirmed primary' do + detached = create(:tag_name, :alias, name: 'detached_nipponese') + + tag = described_class.find_or_create_by_tag_name!( + Locale.nipponese, detached.name, category: :general) + + expect(detached.reload).to have_attributes( + tag_id: tag.id, primary_flg: true, canonical_id: nil, + auto_generated: false) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(detached) + end + + it 'reuses the owning tag for an alias without changing its category' do tag = create(:tag, category: :character) - alias_name = TagName.create!(name: 'lookup_alias', canonical: tag.tag_name) + 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!( + 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 'creates a tag for an existing canonical name reached through an alias' do - canonical = create(:tag_name) - alias_name = TagName.create!(name: 'lookup_alias', canonical:) - tag = nil + 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 { - tag = described_class.find_or_create_by_tag_name!( + found = described_class.find_or_create_by_tag_name!(Locale.nipponese, alias_name.name, category: :general) - }.to change(Tag, :count).by(1).and change(TagName, :count).by(0) + expect(found).to eq(tag) + }.to change(Tag, :count).by(0).and change(TagName, :count).by(0) - expect(tag.tag_name).to eq(canonical) + expect(primary_tag_name_for(tag.reload, 'ja')).to eq(primary) end end describe '.merge_tags!' do let!(:target_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) } - let!(:source_tag_name) { source_tag.tag_name } + let!(:source_tag_name) { primary_tag_name_for(source_tag, 'ja') } let!(:post_record) do Post.create!(url: 'https://example.com/posts/1', title: 'test post') end + context 'with names in multiple languages' do + include_context 'English locale' + + it 'keeps the target primary and moves source names as aliases' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_primary = primary_tag_name_for(target_tag, 'en') + source_primary = primary_tag_name_for(source_tag, 'en') + source_alias = create(:tag_name, :alias, tag: source_tag, + name: 'source_english_alias', language_code: 'en', script_code: 'Latn') + + expect([english_primary, source_primary]).to all(have_attributes( + primary_flg: true, auto_generated: true)) + + described_class.merge_tags!(target_tag, [source_tag]) + + expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary) + expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true)) + .to contain_exactly(english_primary) + expect([source_primary.reload, source_alias.reload]).to all(have_attributes( + tag_id: target_tag.id, language_code: 'en', primary_flg: false)) + expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true)) + .to contain_exactly(primary_tag_name_for(target_tag, 'ja')) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) + end + end + context 'when merging a simple source tag' do let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } @@ -243,7 +489,8 @@ RSpec.describe Tag, type: :model do expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(target_link).to be_present expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -269,15 +516,16 @@ RSpec.describe Tag, type: :model do expect(target_post_tag.reload.sections).to contain_exactly(target_section) expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end it 'keeps source history and records the new target alias after deleting the source' do user = create_member_user! - source_name = source_tag.name - source_alias = TagName.create!(name: 'merge_alias', canonical: source_tag_name) + 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 @@ -287,13 +535,17 @@ RSpec.describe Tag, type: :model do expect(versions.pluck(:version_no, :event_type)) .to eq([[1, 'create'], [2, 'discard']]) expect(versions.first).to eq(original_version) - expect(versions.last).to have_attributes( - name: source_name, aliases: source_alias.name, created_by_user: user) + expect(versions.last.created_by_user).to eq(user) + expect(versions.last.names.fetch('ja')).to eq( + 'name' => source_name, 'script_code' => 'Jpan', + 'auto_generated' => false, + 'aliases' => [{ 'name' => source_alias.name, 'script_code' => 'Jpan' }]) expect(Tag.unscoped.exists?(source_tag.id)).to be(false) target_versions = target_tag.tag_versions.order(:version_no) expect(target_versions.pluck(:event_type)).to eq(['create', 'update']) - expect(target_versions.last.aliases.split).to eq([source_name]) + target_aliases = target_versions.last.names.fetch('ja').fetch('aliases').pluck('name') + expect(target_aliases).to contain_exactly('merge_alias', source_name) end it 'deletes source relationships while preserving unrelated relationships' do @@ -347,7 +599,8 @@ RSpec.describe Tag, type: :model do expect(Tag.unscoped.exists?(source_tag.id)).to be(false) expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false) expect(target_link).to be_present - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -378,7 +631,8 @@ RSpec.describe Tag, type: :model do expect(target_link).to be_present expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id) + expect(source_tag_name.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'ja', primary_flg: false) expect(target_tag.reload.post_count).to eq(1) end end @@ -397,7 +651,7 @@ RSpec.describe Tag, type: :model do it 'rolls back earlier deletions, links, and history when a later source has a wiki' do earlier_source = create(:tag) - earlier_name = earlier_source.tag_name + 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) @@ -407,7 +661,8 @@ RSpec.describe Tag, type: :model do }.to raise_error(ActiveRecord::RecordInvalid) expect(Tag.unscoped.exists?(earlier_source.id)).to be(true) - expect(earlier_name.reload.canonical_id).to be_nil + expect(earlier_name.reload).to have_attributes( + tag_id: earlier_source.id, language_code: 'ja', primary_flg: true) expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id])) .to be_empty expect(Tag.unscoped.exists?(source_tag.id)).to be(true) @@ -416,7 +671,8 @@ RSpec.describe Tag, type: :model do expect(source_post_tag.sections).to contain_exactly(source_section) expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil expect(source_tag.reload.post_count).to eq(1) - expect(source_tag_name.reload.canonical_id).to be_nil + expect(source_tag_name.reload).to have_attributes( + tag_id: source_tag.id, language_code: 'ja', primary_flg: true) expect(target_tag.reload.post_count).to eq(0) end end diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index e76f4e7..561c19b 100644 --- a/backend/spec/models/version_record_spec.rb +++ b/backend/spec/models/version_record_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe VersionRecord, type: :model do - let!(:tag) { create(:tag, name: 'version_record_tag') } + let!(:tag) { create(:tag, primary_name: 'version_record_tag') } let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') } it 'makes TagVersion read only after create' do @@ -9,13 +9,14 @@ RSpec.describe VersionRecord, type: :model do tag: tag, version_no: 1, event_type: 'create', - name: tag.name, + name: primary_tag_name_for(tag, 'ja').name, category: tag.category, aliases: '', + names: { ja: { name: primary_tag_name_for(tag, 'ja').name, + script_code: 'Jpan', auto_generated: false, aliases: [] } }, parent_tag_ids: '', created_at: Time.current, - created_by_user: nil - ) + created_by_user: nil) expect { version.update!(name: 'changed') @@ -27,13 +28,14 @@ RSpec.describe VersionRecord, type: :model do tag: tag, version_no: 1, event_type: 'create', - name: tag.name, + name: primary_tag_name_for(tag, 'ja').name, category: tag.category, aliases: '', + names: { ja: { name: primary_tag_name_for(tag, 'ja').name, + script_code: 'Jpan', auto_generated: false, aliases: [] } }, parent_tag_ids: '', created_at: Time.current, - created_by_user: nil - ) + created_by_user: nil) expect { version.destroy! diff --git a/backend/spec/requests/deerjikists_spec.rb b/backend/spec/requests/deerjikists_spec.rb index 136fc2d..8d26658 100644 --- a/backend/spec/requests/deerjikists_spec.rb +++ b/backend/spec/requests/deerjikists_spec.rb @@ -30,7 +30,7 @@ RSpec.describe 'Deerjikists API', type: :request do expect(json['tag']).to be_a(Hash) expect(json['tag']['id']).to eq(tag1.id) - expect(json['tag']['name']).to eq(tag1.name) + expect(json['tag']['name']).to eq(primary_tag_name_for(tag1, 'ja').name) end end diff --git a/backend/spec/requests/error_responses_spec.rb b/backend/spec/requests/error_responses_spec.rb index a7b5191..81c3ab9 100644 --- a/backend/spec/requests/error_responses_spec.rb +++ b/backend/spec/requests/error_responses_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'error responses', type: :request do it 'returns a stable field-error payload for unprocessable requests' do member = create(:user, :member) - tag = create(:tag, :general, name: 'error_response_tag') + tag = create(:tag, category: :general, primary_name: 'error_response_tag') sign_in_as(member) patch "/tags/#{ tag.id }", params: { category: 'nico' } diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 26f271b..7154683 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -898,23 +898,22 @@ RSpec.describe 'Gekanator learning API', type: :request do describe 'GET /gekanator/questions' do it 'omits questions for deprecated tags' do - active_tag = Tag.create!(name: 'active_question_tag', category: :general) - deprecated_tag = Tag.create!( - name: 'deprecated_question_tag', - category: :general, - deprecated_at: Time.current - ) + active_tag = create(:tag, primary_name: 'active_question_tag', category: :general) + deprecated_tag = create(:tag, + primary_name: 'deprecated_question_tag', + category: :general, + deprecated_at: Time.current) [active_tag, deprecated_tag].each do |question_tag| GekanatorQuestion.create!( - text: "#{ question_tag.name }?", + text: "#{ primary_tag_name_for(question_tag, 'ja').name }?", kind: 'tag', source: 'admin_curated', status: 'accepted', priority_weight: 1.0, condition: { type: 'tag', - key: "#{ question_tag.category }:#{ question_tag.name }" + key: "#{ question_tag.category }:#{ primary_tag_name_for(question_tag, 'ja').name }" }, created_by: admin ) diff --git a/backend/spec/requests/gekanator_posts_spec.rb b/backend/spec/requests/gekanator_posts_spec.rb index b21e414..8cfa46f 100644 --- a/backend/spec/requests/gekanator_posts_spec.rb +++ b/backend/spec/requests/gekanator_posts_spec.rb @@ -4,12 +4,11 @@ require 'rails_helper' RSpec.describe 'Gekanator posts API', type: :request do describe 'GET /gekanator/posts' do it 'omits deprecated tags and returns the stored similarity cosine' do - active_tag = Tag.create!(name: 'active tag', category: :general) - deprecated_tag = Tag.create!( - name: 'deprecated tag', - category: :general, - deprecated_at: Time.current - ) + active_tag = create(:tag, primary_name: 'active tag', category: :general) + deprecated_tag = create(:tag, + primary_name: 'deprecated tag', + category: :general, + deprecated_at: Time.current) post_record = Post.create!(title: 'source', url: 'https://example.com/source') target_post = Post.create!(title: 'target', url: 'https://example.com/target') diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb new file mode 100644 index 0000000..48f2ed1 --- /dev/null +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -0,0 +1,363 @@ +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 + + it 'confirms a generated Nipponese primary when a human renames it' do + tag = create(:tag, primary_name: 'generated_source') + primary = primary_tag_name_for(tag, 'ja') + primary.update!(name: "Tag_##{ tag.id }", auto_generated: true) + + patch "/tags/#{ tag.id }", params: { locale: 'ja', name: '人間が確定した名称' } + + expect(response).to have_http_status(:ok), response.body + expect(primary.reload).to have_attributes( + name: '人間が確定した名称', auto_generated: false) + end + + it 'rolls back Nipponese edits when an alias conflicts with another primary' do + tag = create(:tag, primary_name: 'target_before_collision') + create(:tag_name, :alias, tag:, name: 'existing_alias') + other_tag = create(:tag, primary_name: 'other_primary') + other_primary = primary_tag_name_for(other_tag, 'ja') + primary_name_before = primary_tag_name_for(tag, 'ja').name + aliases_before = tag.tag_names.where(primary_flg: false).order(:name).pluck(:name) + category_before = tag.category + + put "/tags/#{ tag.id }", params: { + locale: 'ja', name: 'renamed_before_collision', category: 'character', + deprecated: false, aliases: 'other_primary', parent_tags: '' } + + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(other_primary.reload).to have_attributes(tag_id: other_tag.id, primary_flg: true) + expect(TagName.where(tag: other_tag, language_code: 'ja', primary_flg: true)) + .to contain_exactly(other_primary) + expect(primary_tag_name_for(tag.reload, 'ja').name).to eq(primary_name_before) + expect(tag.category).to eq(category_before) + expect(tag.tag_names.where(primary_flg: false).order(:name).pluck(:name)) + .to eq(aliases_before) + end + 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 Nipponese fallback' do + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + 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 Nipponese fallback' do + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + 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 + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + # 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 + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + 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 + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + 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 + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + 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 + pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale + locale if requested_locale + 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(:nipponese_tag) do + create(:tag, primary_name: 'temporary_nipponese', 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 + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_tag + nipponese_tag + 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(nipponese_tag) + expect(english_tag.reload.category).to eq('material') + expect(nipponese_tag.reload.category).to eq('general') + end + + it 'updates the post using the English identity' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_tag + nipponese_tag + 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(nipponese_tag) + end + + it 'resolves the English material tag' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_tag + nipponese_tag + 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: nipponese_tag)).to be_empty + end + + it 'resolves the English parent tag' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_tag + nipponese_tag + 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 + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_tag + nipponese_tag + 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 'marks a human-renamed generated primary as no longer auto-generated' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + generated_tag = create(:tag, primary_name: 'rename_generated_source') + generated_name = primary_tag_name_for(generated_tag, 'en') + expect(generated_name.auto_generated).to be(true) + + patch "/tags/#{ generated_tag.id }", params: { + locale: 'en', name: 'human_english_name' } + + expect(response).to have_http_status(:ok), response.body + expect(generated_name.reload).to have_attributes( + name: 'human_english_name', auto_generated: false) + end + + it 'does not steal another tag primary for an alias' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + other_tag = create(:tag, primary_name: 'other_nipponese') + other_primary = primary_tag_name_for(other_tag, 'en') + other_primary.update!(name: 'other_english_primary', auto_generated: false) + create(:tag_name, :alias, tag:, name: 'existing_english_alias', + language_code: 'en', script_code: 'Latn') + primary_name_before = primary_tag_name_for(tag, 'en').name + aliases_before = + tag.tag_names.where(language_code: 'en', primary_flg: false) + .order(:name).pluck(:name) + category_before = tag.category + + put "/tags/#{ tag.id }", params: { + locale: 'en', name: 'renamed_before_alias_collision', + category: 'character', deprecated: false, + aliases: 'other_english_primary', parent_tags: '' } + + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(other_primary.reload).to have_attributes( + tag_id: other_tag.id, language_code: 'en', primary_flg: true) + expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true)) + .to contain_exactly(other_primary) + expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before) + expect(tag.category).to eq(category_before) + aliases_after = + tag.tag_names.where(language_code: 'en', primary_flg: false) + .order(:name).pluck(:name) + expect(aliases_after).to eq(aliases_before) + end + end + + it 'allows a rename to a name already used in Nipponese' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = 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(nipponese.reload, 'ja')).to have_attributes( + name: 'rename_target', language_code: 'ja', tag_id: nipponese.id) + end + + it 'creates aliases in the requested language' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + 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 + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = create(:tag, primary_name: 'nipponese_owner') + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.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(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.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', nipponese.id], ['en', tag.id]) + end + end +end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index c1ffdf4..14b5d8a 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -26,10 +26,10 @@ RSpec.describe 'Materials API', type: :request do describe 'GET /materials' do let!(:tag_a) do - Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) + create(:tag, primary_name: 'material_index_a', category: :material) end let!(:tag_b) do - Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) + create(:tag, primary_name: 'material_index_b', category: :material) end 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 root = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'), + create(:tag, primary_name: 'material_scope_root', category: :material) child_b = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'), + create(:tag, primary_name: 'material_scope_b', category: :material) child_a = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'), + create(:tag, primary_name: 'material_scope_a', category: :material) deprecated = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'), + create(:tag, primary_name: 'material_scope_old', category: :material, deprecated_at: Time.current) grandchild = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'), + create(:tag, primary_name: 'material_scope_grandchild', category: :material) root_material = build_material(tag: root, user: member_user, @@ -179,7 +179,7 @@ RSpec.describe 'Materials API', type: :request do describe 'GET /materials/:id' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) + create(:tag, primary_name: 'material_show', category: :material) end let!(:material) do 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) material = Material.order(:id).last - expect(material.tag.name).to eq('material_create_new') + expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_create_new') expect(material.tag.category).to eq('material') expect(material.created_by_user).to eq(member_user) expect(material.updated_by_user).to eq(member_user) @@ -297,7 +297,8 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:created) - tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' }) + tag = TagName.find_by!(name: 'material_create_versioned_tag', + language_code: 'ja', primary_flg: true).tag version = tag.tag_versions.order(:version_no).last expect(version.event_type).to eq('create') @@ -323,8 +324,8 @@ RSpec.describe 'Materials API', type: :request do end it 'returns 422 when the existing tag is not material/character' do - general_tag_name = TagName.create!(name: 'material_create_general_tag') - Tag.create!(tag_name: general_tag_name, category: :general) + general_tag_name = create(:tag_name, name: 'material_create_general_tag') + create(:tag, primary_tag_name: general_tag_name, category: :general) post '/materials', params: { tag: 'material_create_general_tag', @@ -345,7 +346,7 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:created) material = Material.order(:id).last - expect(material.tag.name).to eq('material_create_url_only') + expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_create_url_only') expect(material.url).to eq('https://example.com/material-source') expect(material.file.attached?).to be(false) end @@ -384,7 +385,7 @@ RSpec.describe 'Materials API', type: :request do describe 'PUT /materials/:id' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) + create(:tag, primary_name: 'material_update_old', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) @@ -463,7 +464,7 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:ok) material.reload - expect(material.tag.name).to eq('material_update_new') + expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_update_new') expect(material.tag.category).to eq('material') expect(material.url).to eq('https://example.com/updated-source') expect(material.updated_by_user).to eq(member_user) @@ -497,7 +498,8 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:ok) - tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' }) + tag = TagName.find_by!(name: 'material_update_versioned_tag', + language_code: 'ja', primary_flg: true).tag version = tag.tag_versions.order(:version_no).last expect(version.event_type).to eq('create') @@ -508,7 +510,7 @@ RSpec.describe 'Materials API', type: :request do it 'backfills a create tag_version for an existing material tag without history' do existing_tag = - Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'), + create(:tag, primary_name: 'material_update_existing_no_history', category: :material) expect(existing_tag.tag_versions).to be_empty @@ -531,7 +533,7 @@ RSpec.describe 'Materials API', type: :request do it 'backfills a create tag_version for an existing character tag without history' do existing_tag = - Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'), + create(:tag, primary_name: 'material_update_character_no_history', category: :character) expect(existing_tag.tag_versions).to be_empty @@ -563,7 +565,7 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:ok) material.reload - expect(material.tag.name).to eq('material_update_remove_file') + expect(primary_tag_name_for(material.tag, 'ja').name).to eq('material_update_remove_file') expect(material.url).to eq('https://example.com/updated-source') expect(material.updated_by_user).to eq(member_user) expect(material.file.attached?).to be(false) @@ -651,8 +653,8 @@ RSpec.describe 'Materials API', type: :request do end describe 'GET /materials/download.zip' do - let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) } - let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) } + let!(:tag_a) { create(:tag, primary_name: 'zip_a', category: :material) } + let!(:tag_b) { create(:tag, primary_name: 'zip_b', category: :material) } let!(:material_a) do build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png', body: 'zip-a')) @@ -701,7 +703,7 @@ RSpec.describe 'Materials API', type: :request do describe 'GET /materials/versions' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material) + create(:tag, primary_name: 'material_history', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) @@ -776,7 +778,7 @@ RSpec.describe 'Materials API', type: :request do describe 'DELETE /materials/:id' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) + create(:tag, primary_name: 'material_destroy', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 243cbd3..50fdd0d 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -44,10 +44,10 @@ RSpec.describe 'NicoTags', type: :request do unlinked.update!(name: 'search_unlinked') other = create(:external_tag) other.update!(name: 'other') - destination = create(:tag, :general) - destination.tag_name.update!(name: 'destination_search') + destination = create(:tag, category: :general) + primary_tag_name_for(destination, 'ja').update!(name: 'destination_search') NicoTagRelation.create!(nico_tag: linked, tag: destination) - NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general)) + NicoTagRelation.create!(nico_tag: other, tag: create(:tag, category: :general)) get '/tags/nico', params: { name: 'search_', @@ -151,7 +151,7 @@ RSpec.describe 'NicoTags', type: :request do it '404 when only an internal tag exists for the target id' do sign_in_as(member) - non_nico = create(:tag, :general) + non_nico = create(:tag, category: :general) expect(ExternalTag.exists?(non_nico.id)).to be(false) patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } expect(response).to have_http_status(:not_found) @@ -162,18 +162,18 @@ RSpec.describe 'NicoTags', type: :request do nico_tag = create(:external_tag, name: 'nico_tags_spec_source') - linked_a_name = TagName.create!(name: 'nico_linked_a') - linked_a = Tag.create!(tag_name: linked_a_name, category: :general) + linked_a_name = create(:tag_name, name: 'nico_linked_a') + linked_a = create(:tag, primary_tag_name: linked_a_name, category: :general) - linked_b_name = TagName.create!(name: 'nico_linked_b') - linked_b = Tag.create!(tag_name: linked_b_name, category: :general) + linked_b_name = create(:tag_name, name: 'nico_linked_b') + linked_b = create(:tag, primary_tag_name: linked_b_name, category: :general) NicoTagVersionRecorder.record!(external_tag: nico_tag, event_type: :create, created_by_user: admin) expect { patch "/tags/nico/#{nico_tag.id}", params: { - tags: " #{linked_a.name}\n#{linked_b.name} " + tags: " #{ linked_a_name.name }\n#{ linked_b_name.name } " } }.to change(TagVersion, :count).by(2) .and change(NicoTagVersion, :count).by(1) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 8660634..38bc115 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -113,8 +113,8 @@ RSpec.describe 'Posts API', type: :request do 'sections' => []) end - let!(:tag_name) { TagName.create!(name: 'spec_tag') } - let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } + let!(:tag_name) { create(:tag_name, name: 'spec_tag') } + let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) } let!(:post_record) do Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p| @@ -125,11 +125,11 @@ RSpec.describe 'Posts API', type: :request do describe 'GET /posts' do let!(:user) { create_member_user! } - let!(:tag_name) { TagName.create!(name: 'spec_tag') } - let!(:tag) { Tag.create!(tag_name:, category: :general) } - let!(:tag_name2) { TagName.create!(name: 'unko') } - let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) } - let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } + let!(:tag_name) { create(:tag_name, name: 'spec_tag') } + let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) } + let!(:tag_name2) { create(:tag_name, name: 'unko') } + let!(:tag2) { create(:tag, primary_tag_name: tag_name2, category: :deerjikist) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } let!(:hit_post) do Post.create!(uploaded_user: user, title: 'hello spec world', @@ -267,7 +267,7 @@ RSpec.describe 'Posts API', type: :request do end it 'keeps internal name searches independent of colliding external ids' do - get '/posts', params: { tags: tag.name } + 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) @@ -286,7 +286,7 @@ RSpec.describe 'Posts API', type: :request do [nil, 'all'].each do |match| it "intersects internal and external matches with match=#{ match || 'omitted' }" do - params = { tags: "#{ tag.name } nico:search_external" } + params = { tags: "#{ primary_tag_name_for(tag, 'ja').name } nico:search_external" } params[:match] = match if match get '/posts', params: params @@ -307,7 +307,8 @@ RSpec.describe 'Posts API', type: :request do end it 'excludes external matches from an internal tag search' do - get '/posts', params: { tags: "#{ tag.name } not:nico:search_external" } + 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) @@ -316,7 +317,9 @@ RSpec.describe 'Posts API', type: :request do end it 'unions an external match with a negated internal match' do - get '/posts', params: { tags: "nico:search_external not:#{ tag.name }", match: 'any' } + 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) @@ -333,7 +336,9 @@ RSpec.describe 'Posts API', type: :request do end it 'applies the same mixed name search to the existing random endpoint' do - get '/posts/random', params: { tags: "#{ tag.name } nico:search_external", match: 'all' } + 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) @@ -341,17 +346,17 @@ RSpec.describe 'Posts API', type: :request do end context 'when tags contain not:' do - let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') } - let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) } + let!(:foo_tag_name) { create(:tag_name, name: 'not_spec_foo') } + let!(:foo_tag) { create(:tag, primary_tag_name: foo_tag_name, category: :general) } - let!(:bar_tag_name) { TagName.create!(name: 'not_spec_bar') } - let!(:bar_tag) { Tag.create!(tag_name: bar_tag_name, category: :general) } + let!(:bar_tag_name) { create(:tag_name, name: 'not_spec_bar') } + let!(:bar_tag) { create(:tag, primary_tag_name: bar_tag_name, category: :general) } - let!(:baz_tag_name) { TagName.create!(name: 'not_spec_baz') } - let!(:baz_tag) { Tag.create!(tag_name: baz_tag_name, category: :general) } + let!(:baz_tag_name) { create(:tag_name, name: 'not_spec_baz') } + let!(:baz_tag) { create(:tag, primary_tag_name: baz_tag_name, category: :general) } let!(:foo_alias_tag_name) do - TagName.create!(name: 'not_spec_foo_alias', canonical: foo_tag_name) + create(:tag_name, :alias, name: 'not_spec_foo_alias', tag: foo_tag) end let!(:foo_only_post) do @@ -739,7 +744,9 @@ RSpec.describe 'Posts API', type: :request do .find { _1['name'] == 'nico:post_index_external' } expect(post_json.fetch('tags')).to include( - a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general')) + 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 @@ -772,11 +779,10 @@ RSpec.describe 'Posts API', type: :request do end it 'omits deprecated tags' do - deprecated_tag = Tag.create!( - name: 'deprecated_post_tag', - category: :general, - deprecated_at: Time.current - ) + deprecated_tag = create(:tag, + primary_name: 'deprecated_post_tag', + category: :general, + deprecated_at: Time.current) PostTag.create!(post: post_record, tag: deprecated_tag) request @@ -853,9 +859,9 @@ RSpec.describe 'Posts API', type: :request do tags = 15.times.map do |i| - tag_name = TagName.create!(name: "show_query_tag_#{ i }") - tag = Tag.create!(tag_name:, category: :general) - TagName.create!(name: "show_query_alias_#{ i }", canonical: tag_name) + tag_name = create(:tag_name, name: "show_query_tag_#{ i }") + tag = create(:tag, primary_tag_name: tag_name, category: :general) + create(:tag_name, :alias, name: "show_query_alias_#{ i }", tag:) PostTag.create!(post: post_record, tag:) tag end @@ -910,7 +916,9 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:ok) expect(json.fetch('tags')).to include( - a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general')) + a_hash_including('id' => tag.id, + 'name' => primary_tag_name_for(tag, 'ja').name, + 'category' => 'general')) external_json = json .fetch('tags') @@ -1000,7 +1008,7 @@ RSpec.describe 'Posts API', type: :request do describe 'POST /posts' do let(:member) { create(:user, :member) } - let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } it '401 when not logged in' do sign_out @@ -1157,11 +1165,10 @@ RSpec.describe 'Posts API', type: :request do end it 'rejects a deprecated tag specified directly' do - Tag.create!( - name: 'deprecated_direct_tag', - category: :general, - deprecated_at: Time.current - ) + create(:tag, + primary_name: 'deprecated_direct_tag', + category: :general, + deprecated_at: Time.current) sign_in_as(member) post '/posts', params: post_write_params( @@ -1178,18 +1185,16 @@ RSpec.describe 'Posts API', type: :request do end it 'expands through multiple deprecated parent tags and saves active ancestors' do - child = Tag.create!(name: 'active_child', category: :general) - deprecated_parent = Tag.create!( - name: 'deprecated_parent', - category: :general, - deprecated_at: Time.current - ) - deprecated_grandparent = Tag.create!( - name: 'deprecated_grandparent', - category: :general, - deprecated_at: Time.current - ) - active_grandparent = Tag.create!(name: 'active_grandparent', category: :general) + child = create(:tag, primary_name: 'active_child', category: :general) + deprecated_parent = create(:tag, + primary_name: 'deprecated_parent', + category: :general, + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + primary_name: 'deprecated_grandparent', + category: :general, + deprecated_at: Time.current) + active_grandparent = create(:tag, primary_name: 'active_grandparent', category: :general) TagImplication.create!(tag: child, parent_tag: deprecated_parent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent) @@ -1203,7 +1208,8 @@ RSpec.describe 'Posts API', type: :request do ) expect(response).to have_http_status(:created) - saved_names = Post.find(json.fetch('id')).tags.map(&:name) + saved_names = TagName.where(tag_id: Post.find(json.fetch('id')).tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name) expect(saved_names).to include('active_child', 'active_grandparent') expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent') end @@ -1306,7 +1312,8 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:created) created_post = Post.find(json.fetch('id')) - tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) + tag = TagName.find_by!(name: '伊地知ニジカ', + language_code: 'ja', primary_flg: true).tag section = PostTagSection.find_by!(post: created_post, tag:) expect(section.begin_ms).to eq(60_000) @@ -1326,7 +1333,8 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:created) created_post = Post.find(json.fetch('id')) - tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) + tag = TagName.find_by!(name: '伊地知ニジカ', + language_code: 'ja', primary_flg: true).tag expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil end @@ -1343,7 +1351,8 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:created) created_post = Post.find(json.fetch('id')) - tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' }) + tag = TagName.find_by!(name: '伊地知ニジカ', + language_code: 'ja', primary_flg: true).tag expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil end @@ -1622,6 +1631,7 @@ RSpec.describe 'Posts API', type: :request do expect(arguments[:thumbnails].keys).to eq([0]) expect(arguments[:host]).to eq('http://www.example.com') end + expect(creator).to have_received(:run).with(Locale.nipponese) end it 'rejects malformed manifests as a request-level error' do @@ -1658,8 +1668,8 @@ RSpec.describe 'Posts API', type: :request do create(:post_tag_section, post: post_record, tag:, begin_ms: 1000, end_ms: 2000) - tn2 = TagName.create!(name: 'spec_tag_2') - replacement_tag = Tag.create!(tag_name: tn2, category: :general) + tn2 = create(:tag_name, name: 'spec_tag_2') + replacement_tag = create(:tag, primary_tag_name: tn2, category: :general) put "/posts/#{post_record.id}", params: post_update_params( post_record, @@ -1707,11 +1717,10 @@ RSpec.describe 'Posts API', type: :request do end it 'rejects a deprecated tag specified directly' do - Tag.create!( - name: 'deprecated_update_tag', - category: :general, - deprecated_at: Time.current - ) + create(:tag, + primary_name: 'deprecated_update_tag', + category: :general, + deprecated_at: Time.current) sign_in_as(member) put "/posts/#{ post_record.id }", params: post_update_params( @@ -1966,7 +1975,7 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, title: 'updated by me', - tags: "spec_tag #{Tag.no_deerjikist.name}") + tags: "spec_tag #{primary_tag_name_for(Tag.no_deerjikist, 'ja').name}") expect(response).to have_http_status(:conflict) @@ -1986,7 +1995,8 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) - current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general) + current_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'current_added_tag', category: :general) PostTag.create!(post: post_record, tag: current_tag, created_user: member) PostVersionRecorder.record!( @@ -1997,7 +2007,8 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, title: post_record.title, - tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag") + tags: ['spec_tag', primary_tag_name_for(Tag.no_deerjikist, 'ja').name, + 'incoming_added_tag'].join(' ')) expect(response).to have_http_status(:conflict) @@ -2015,7 +2026,8 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) - current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general) + current_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'current_merge_tag', category: :general) PostTag.create!(post: post_record, tag: current_tag, created_user: member) PostVersionRecorder.record!( @@ -2026,15 +2038,17 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, title: post_record.title, - tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag", + tags: ['spec_tag', primary_tag_name_for(Tag.no_deerjikist, 'ja').name, + 'incoming_merge_tag'].join(' '), merge: '1') expect(response).to have_http_status(:ok) - names = post_record.reload.tags.map(&:name) + names = TagName.where(tag_id: post_record.reload.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name) expect(names).to include('spec_tag') - expect(names).to include(Tag.no_deerjikist.name) + expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(names).to include('current_merge_tag') expect(names).to include('incoming_merge_tag') end @@ -2057,14 +2071,15 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, title: post_record.title, - tags: "spec_tag #{ Tag.no_deerjikist.name }") + tags: "spec_tag #{ primary_tag_name_for(Tag.no_deerjikist, 'ja').name }") expect(response).to have_http_status(:ok) - names = post_record.reload.tags.map(&:name) + names = TagName.where(tag_id: post_record.reload.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name) expect(names).to include('spec_tag') - expect(names).to include(Tag.no_deerjikist.name) + expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(post_record.external_tags).to contain_exactly(nico_tag) end @@ -2079,14 +2094,15 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, title: 'updated title', - tags: "spec_tag #{ Tag.no_deerjikist.name }") + tags: "spec_tag #{ primary_tag_name_for(Tag.no_deerjikist, 'ja').name }") expect(response).to have_http_status(:ok) - names = post_record.reload.tags.map(&:name) + names = TagName.where(tag_id: post_record.reload.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name) expect(names).to include('spec_tag') - expect(names).to include(Tag.no_deerjikist.name) + expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) expect(post_record.external_tags).to contain_exactly(nico_tag) external_json = @@ -2102,7 +2118,8 @@ RSpec.describe 'Posts API', type: :request do sign_in_as(member) nico_tag = create_nico_tag!('nico:relation_source') - linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general) + linked_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'relation_linked_tag', category: :general) NicoTagRelation.create!(nico_tag:, tag: linked_tag) PostExternalTag.create!(post: post_record, external_tag: nico_tag) @@ -2113,16 +2130,17 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, title: post_record.title, - tags: "spec_tag #{ Tag.no_deerjikist.name }") + tags: "spec_tag #{ primary_tag_name_for(Tag.no_deerjikist, 'ja').name }") expect(response).to have_http_status(:ok) - names = post_record.reload.tags.map(&:name) + names = TagName.where(tag_id: post_record.reload.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name) expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include('spec_tag') - expect(names).to include(Tag.no_deerjikist.name) - expect(names).not_to include(linked_tag.name) + expect(names).to include(primary_tag_name_for(Tag.no_deerjikist, 'ja').name) + expect(names).not_to include(primary_tag_name_for(linked_tag, 'ja').name) end it 'force-updates stale posts without base_version_no' do @@ -2138,7 +2156,7 @@ RSpec.describe 'Posts API', type: :request do put "/posts/#{post_record.id}", params: post_write_params( title: 'forced title', - tags: "spec_tag #{Tag.no_deerjikist.name}", + tags: "spec_tag #{primary_tag_name_for(Tag.no_deerjikist, 'ja').name}", force: '1') expect(response).to have_http_status(:ok) @@ -2191,8 +2209,8 @@ RSpec.describe 'Posts API', type: :request do 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!(:tag_name2) { TagName.create!(name: 'spec_tag_2') } - let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) } + let!(:tag_name2) { create(:tag_name, name: 'spec_tag_2') } + let!(:tag2) { create(:tag, primary_tag_name: tag_name2, category: :general) } def snapshot_tags(post) post.snapshot_tag_names.join(' ') @@ -2417,7 +2435,7 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:ok) expect(json.fetch('count')).to eq(3) expect(json.fetch('versions').first.fetch('tags')).to include( - 'name' => tag2.name, 'type' => 'context') + 'name' => primary_tag_name_for(tag2, 'ja').name, 'type' => 'context') end it 'filters versions by tag when the current snapshot includes the tag' do @@ -2550,8 +2568,8 @@ RSpec.describe 'Posts API', type: :request do sign_in_as(member) base_version = create_post_version_for!(post_record) - tag_name2 = TagName.create!(name: 'spec_tag_2') - Tag.create!(tag_name: tag_name2, category: :general) + tag_name2 = create(:tag_name, name: 'spec_tag_2') + create(:tag, primary_tag_name: tag_name2, category: :general) expect do put "/posts/#{post_record.id}", params: post_write_params( @@ -2715,8 +2733,8 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) - tag_name2 = TagName.create!(name: 'spec_tag_2') - tag2 = Tag.create!(tag_name: tag_name2, category: :general) + tag_name2 = create(:tag_name, name: 'spec_tag_2') + tag2 = create(:tag, primary_tag_name: tag_name2, category: :general) expect { put "/posts/#{post_record.id}", params: post_write_params( diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 62afeb8..f105773 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -3,13 +3,13 @@ require 'rails_helper' RSpec.describe 'TagVersions API', type: :request do let(:member) { create(:user, :member, name: 'version member') } - let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) } - let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) } + let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) } + let!(:other_tag) { create(:tag, primary_name: 'tag_versions_other', category: :general) } - let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) } - let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) } - let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) } - let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) } + let!(:parent_shared) { create(:tag, primary_name: 'parent_shared', category: :general) } + let!(:parent_old) { create(:tag, primary_name: 'parent_old', category: :general) } + let!(:parent_new) { create(:tag, primary_name: 'parent_new', category: :general) } + let!(:other_parent) { create(:tag, primary_name: 'other_parent', category: :general) } let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } @@ -36,6 +36,10 @@ RSpec.describe 'TagVersions API', type: :request do category: category, deprecated_at: deprecated_at, aliases: Array(aliases).join(' '), + names: { ja: { name:, script_code: 'Jpan', auto_generated: false, + aliases: Array(aliases).sort.map { |alias_name| + { name: alias_name, script_code: 'Jpan' } + } } }, parent_tag_ids: Array(parent_tags).map(&:id).join(' '), created_by_user: created_by_user, created_at: created_at) @@ -211,7 +215,7 @@ RSpec.describe 'TagVersions API', type: :request do end it 'returns empty when the specified tag has no versions' do - fresh_tag = create(:tag, name: 'no_versions_tag', category: :general) + fresh_tag = create(:tag, primary_name: 'no_versions_tag', category: :general) get '/tags/versions', params: { id: fresh_tag.id } @@ -232,8 +236,8 @@ RSpec.describe 'TagVersions API', type: :request do end it 'does not create tag versions by wiki updates when tag has no versions yet' do - wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki') - wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) + wiki_tag_name = create(:tag_name, name: 'tag_versions_from_wiki') + wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general) wiki_page = Wiki::Commit.create_content!( diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 4914cf4..a464c05 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -8,13 +8,13 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end def create_tag! name:, category: :general - tag_name = TagName.create!(name:) - Tag.create!(tag_name:, category:) + tag_name = create(:tag_name, name:) + create(:tag, primary_tag_name: tag_name, category:) end def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body:, created_by_user: user, message: 'init') @@ -43,7 +43,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do wiki_page.reload version = wiki_page.wiki_versions.order(:version_no).last - expect(tag.name).to eq('patch_tag_wiki_after') + expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') expect(wiki_page.title).to eq('patch_tag_wiki_after') expect(version).to have_attributes( @@ -72,7 +72,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do tag.reload wiki_page.reload - expect(tag.name).to eq('patch_tag_category_only') + expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only') expect(tag.category).to eq('meme') expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) end @@ -101,7 +101,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do wiki_page.reload version = wiki_page.wiki_versions.order(:version_no).last - expect(tag.name).to eq('put_tag_wiki_after') + expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') expect(wiki_page.title).to eq('put_tag_wiki_after') expect(version).to have_attributes( @@ -134,7 +134,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do tag.reload wiki_page.reload - expect(tag.name).to eq('put_tag_category_only') + expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only') expect(tag.category).to eq('meme') expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) end diff --git a/backend/spec/requests/tags_deerjikists_spec.rb b/backend/spec/requests/tags_deerjikists_spec.rb index e0d3a3e..e0c2314 100644 --- a/backend/spec/requests/tags_deerjikists_spec.rb +++ b/backend/spec/requests/tags_deerjikists_spec.rb @@ -12,7 +12,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do let(:guest) { create(:user, role: :guest) } before do - tag.tag_name.update!(name: 'deerjika') + primary_tag_name_for(tag, 'ja').update!(name: 'deerjika') end describe 'GET /tags/:id/deerjikists' do @@ -344,7 +344,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do end before do - other_tag.tag_name.update!(name: 'existing-deerjikist') + 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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 3c1088c..c8bfd54 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -2,13 +2,13 @@ require 'cgi' require 'rails_helper' RSpec.describe 'Tags API', type: :request do - let!(:tn) { TagName.create!(name: 'spec_tag') } - let!(:tag) { Tag.create!(tag_name: tn, category: :general) } - let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) } + let!(:tn) { create(:tag_name, name: 'spec_tag') } + let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) } + let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) } let!(:post) { Post.create!(url: 'https://example.com/unkounkounko') } let!(:post_tag) { PostTag.create!(post:, tag:) } - let!(:tn2) { TagName.create!(name: 'unknown') } - let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) } + let!(:tn2) { create(:tag_name, name: 'unknown') } + let!(:tag2) { create(:tag, primary_tag_name: tn2, category: :general) } def response_tags json.fetch('tags') @@ -18,6 +18,14 @@ RSpec.describe 'Tags API', type: :request do response_tags.map { |t| t.fetch('name') } end + def version_primary_name(version, language_code) + version.names.fetch(language_code).fetch('name') + end + + def version_alias_names(version, language_code) + version.names.fetch(language_code).fetch('aliases').pluck('name') + end + def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename) end @@ -38,7 +46,9 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) expect(json.fetch('count')).to eq(2) expect(response_tags).to contain_exactly( - a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'), + a_hash_including('id' => tag.id, + 'name' => primary_tag_name_for(tag, 'ja').name, + 'category' => 'general'), a_hash_including( 'id' => external.id, 'name' => 'nico:spec_external', @@ -91,7 +101,7 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by category' do - meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme) + meme = create(:tag, primary_name: 'meme_only', category: :meme) get '/tags', params: { category: 'meme' } @@ -101,30 +111,29 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by deprecated state' do - deprecated_tag = Tag.create!( - name: 'deprecated_filter', - category: :general, - deprecated_at: 1.day.from_now - ) - active_tag = Tag.create!(name: 'active_filter', category: :general) + deprecated_tag = create(:tag, + primary_name: 'deprecated_filter', + category: :general, + deprecated_at: 1.day.from_now) + active_tag = create(:tag, primary_name: 'active_filter', category: :general) get '/tags', params: { name: '_filter', deprecated: '1' } expect(response).to have_http_status(:ok) - expect(response_names).to include(deprecated_tag.name) - expect(response_names).not_to include(active_tag.name) + expect(response_names).to include(primary_tag_name_for(deprecated_tag, 'ja').name) + expect(response_names).not_to include(primary_tag_name_for(active_tag, 'ja').name) get '/tags', params: { name: '_filter', deprecated: '0' } expect(response).to have_http_status(:ok) - expect(response_names).to include(active_tag.name) - expect(response_names).not_to include(deprecated_tag.name) + expect(response_names).to include(primary_tag_name_for(active_tag, 'ja').name) + expect(response_names).not_to include(primary_tag_name_for(deprecated_tag, 'ja').name) end it 'filters tags by post_count range' do - low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) - mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general) - high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general) + low = create(:tag, primary_name: 'pc_low', category: :general) + mid = create(:tag, primary_name: 'pc_mid', category: :general) + high = create(:tag, primary_name: 'pc_high', category: :general) low.update_columns(post_count: 1) mid.update_columns(post_count: 3) @@ -142,8 +151,8 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by created_at range' do - old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general) - new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general) + old_tag = create(:tag, primary_name: 'created_old', category: :general) + new_tag = create(:tag, primary_name: 'created_new', category: :general) old_time = Time.zone.local(2024, 1, 1, 0, 0, 0) new_time = Time.zone.local(2024, 2, 1, 0, 0, 0) @@ -162,8 +171,8 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by updated_at range' do - old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general) - new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general) + old_tag = create(:tag, primary_name: 'updated_old', category: :general) + new_tag = create(:tag, primary_name: 'updated_new', category: :general) old_time = Time.zone.local(2024, 3, 1, 0, 0, 0) new_time = Time.zone.local(2024, 4, 1, 0, 0, 0) @@ -182,12 +191,12 @@ RSpec.describe 'Tags API', type: :request do end it 'orders tags by custom category order' do - Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist) - Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme) - Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character) - Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) - Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) - Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) + create(:tag, primary_name: 'cat_deerjikist', category: :deerjikist) + create(:tag, primary_name: 'cat_meme', category: :meme) + create(:tag, primary_name: 'cat_character', category: :character) + create(:tag, primary_name: 'cat_general', category: :general) + create(:tag, primary_name: 'cat_material', category: :material) + create(:tag, primary_name: 'cat_meta', category: :meta) create(:external_tag, name: 'cat_nico') get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } @@ -202,20 +211,20 @@ RSpec.describe 'Tags API', type: :request do context 'with mixed legacy pagination' do let!(:first_tag) do create(:tag, - tag_name: create(:tag_name, name: 'a_mixed_page'), - category: :meme) + primary_tag_name: create(:tag_name, name: 'a_mixed_page'), + category: :meme) end let!(:middle_tag) do create(:tag, - tag_name: create(:tag_name, name: 'm_mixed_page'), - category: :meta) + primary_tag_name: create(:tag_name, name: 'm_mixed_page'), + category: :meta) end let!(:last_tag) do create(:tag, - tag_name: create(:tag_name, name: 'z_mixed_page'), - category: :general) + primary_tag_name: create(:tag_name, name: 'z_mixed_page'), + category: :general) end let!(:first_external) do @@ -265,7 +274,7 @@ RSpec.describe 'Tags API', type: :request do it 'paginates and keeps total count' do %w[pag_a pag_b pag_c].each do |name| - Tag.create!(tag_name: TagName.create!(name:), category: :general) + create(:tag, primary_tag_name: create(:tag_name, name:), category: :general) end get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 } @@ -276,8 +285,8 @@ RSpec.describe 'Tags API', type: :request do end it 'falls back to default ordering when order is invalid' do - low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general) - high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general) + low = create(:tag, primary_name: 'fallback_low', category: :general) + high = create(:tag, primary_name: 'fallback_high', category: :general) low.update_columns(post_count: 1) high.update_columns(post_count: 9) @@ -290,7 +299,7 @@ RSpec.describe 'Tags API', type: :request do it 'normalises invalid page and limit' do %w[norm_a norm_b].each do |name| - Tag.create!(tag_name: TagName.create!(name:), category: :general) + create(:tag, primary_tag_name: create(:tag_name, name:), category: :general) end get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 } @@ -302,10 +311,9 @@ RSpec.describe 'Tags API', type: :request do end it 'returns aliases and parent tags' do - parent_tag = Tag.create!( - tag_name: TagName.create!(name: 'index_parent_tag'), - category: :meme - ) + parent_tag = create(:tag, + primary_tag_name: create(:tag_name, name: 'index_parent_tag'), + category: :meme) TagImplication.create!(tag:, parent_tag:) get '/tags', params: { name: 'spec_tag' } @@ -349,10 +357,9 @@ RSpec.describe 'Tags API', type: :request do end it 'returns aliases and parent tags' do - parent_tag = Tag.create!( - tag_name: TagName.create!(name: 'show_parent_tag'), - category: :character - ) + parent_tag = create(:tag, + primary_tag_name: create(:tag_name, name: 'show_parent_tag'), + category: :character) TagImplication.create!(tag:, parent_tag:) request @@ -398,7 +405,7 @@ RSpec.describe 'Tags API', type: :request do it 'prefers Tag when Tag and ExternalTag have the same id' do internal = create( :tag, - tag_name: create(:tag_name, name: 'internal_collision')) + primary_tag_name: create(:tag_name, name: 'internal_collision')) create( :external_tag, id: internal.id, @@ -416,7 +423,7 @@ RSpec.describe 'Tags API', type: :request do describe 'GET /tags/autocomplete' do it 'combines internal and external matches without conflating equal ids' do - internal = Tag.create!(category: :general, name: 'mixed_internal', post_count: 2) + internal = create(:tag, category: :general, primary_name: 'mixed_internal', post_count: 2) external = create(:external_tag, id: internal.id, name: 'mixed_external', post_count: 3) @@ -455,7 +462,7 @@ RSpec.describe 'Tags API', type: :request do it 'limits the combined results to 20 and sorts ties by displayed name' do 11.times do |i| name = "combined_#{ i.to_s.rjust(2, '0') }" - Tag.create!(category: :general, name:, post_count: 1) + create(:tag, category: :general, primary_name: name, post_count: 1) create(:external_tag, name:, post_count: 1) end @@ -467,34 +474,39 @@ RSpec.describe 'Tags API', type: :request do end it 'excludes external tags but preserves internal alias matches when nico is false' do - internal = Tag.create!(category: :general, name: 'switch_internal', post_count: 1) - alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1) - TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name) + internal = create(:tag, category: :general, primary_name: 'switch_internal', post_count: 1) + alias_target = create(:tag, category: :general, primary_name: 'alias_target', post_count: 1) + create(:tag_name, :alias, name: 'switch_alias', tag: alias_target) create(:external_tag, name: 'switch_external', post_count: 1) get '/tags/autocomplete', params: { q: 'switch', nico: '0' } expect(response).to have_http_status(:ok) expect(json).to contain_exactly( - a_hash_including('id' => internal.id, 'name' => internal.name), - a_hash_including('id' => alias_target.id, 'name' => alias_target.name, + a_hash_including('id' => internal.id, + 'name' => primary_tag_name_for(internal, 'ja').name), + a_hash_including('id' => alias_target.id, + 'name' => primary_tag_name_for(alias_target, 'ja').name, 'matched_alias' => 'switch_alias')) end ['%', '_'].each do |wildcard| - it "treats #{ wildcard } literally for canonical, alias, and external names" do + it "treats #{ wildcard } literally for primary, alias, and external names" do literal = "literal#{ wildcard }match" - Tag.create!(category: :general, name: literal, post_count: 1) - alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1) - TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name) + create(:tag, category: :general, primary_name: literal, post_count: 1) + alias_target = create( + :tag, category: :general, primary_name: 'literal_alias_target', post_count: 1) + create(:tag_name, :alias, name: "#{ literal }_alias", + tag: alias_target) create(:external_tag, name: literal, post_count: 1) - Tag.create!(category: :general, name: 'literalXmatch', post_count: 2) + create(:tag, category: :general, primary_name: 'literalXmatch', post_count: 2) create(:external_tag, name: 'literalXmatch', post_count: 2) get '/tags/autocomplete', params: { q: "literal#{ wildcard }" } expect(json.map { |row| row.fetch('name') }) - .to contain_exactly(literal, alias_target.name, "nico:#{ literal }") + .to contain_exactly( + literal, primary_tag_name_for(alias_target, 'ja').name, "nico:#{ literal }") end end @@ -510,7 +522,7 @@ RSpec.describe 'Tags API', type: :request do expect(t['matched_alias']).to be(nil) end - it 'returns matching canonical tags by q with aliases' do + it 'returns matching primary tag names by q with aliases' do get '/tags/autocomplete', params: { q: 'unk' } expect(response).to have_http_status(:ok) @@ -523,11 +535,10 @@ RSpec.describe 'Tags API', type: :request do end it 'omits deprecated tags' do - deprecated_tag = Tag.create!( - name: 'spec_deprecated', - category: :general, - deprecated_at: Time.current - ) + deprecated_tag = create(:tag, + primary_name: 'spec_deprecated', + category: :general, + deprecated_at: Time.current) deprecated_tag.update_columns(post_count: 1) get '/tags/autocomplete', params: { q: 'spec_', present: '0' } @@ -621,7 +632,7 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq('new') + expect(primary_tag_name_for(tag, 'ja').name).to eq('new') expect(tag.category).to eq('general') body = JSON.parse(response.body) @@ -636,7 +647,7 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq('spec_tag') + expect(primary_tag_name_for(tag, 'ja').name).to eq('spec_tag') expect(tag.category).to eq('meme') end @@ -646,7 +657,7 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq('spec_tag') + expect(primary_tag_name_for(tag, 'ja').name).to eq('spec_tag') expect(tag.category).to eq('general') end @@ -656,7 +667,7 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq('n') + expect(primary_tag_name_for(tag, 'ja').name).to eq('n') expect(tag.category).to eq('meta') end @@ -669,7 +680,7 @@ RSpec.describe 'Tags API', type: :request do patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } expect(response).to have_http_status(:unprocessable_entity) - expect(tag.reload.name).to eq('spec_tag') + expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') expect(tag.category).to eq('general') end @@ -684,11 +695,11 @@ RSpec.describe 'Tags API', type: :request do expect(versions.map(&:event_type)).to eq(['create', 'update']) - expect(versions.first.name).to eq('spec_tag') + expect(version_primary_name(versions.first, 'ja')).to eq('spec_tag') expect(versions.first.category).to eq('general') - expect(versions.first.aliases.split).to include('unko') + expect(version_alias_names(versions.first, 'ja')).to include('unko') - expect(versions.second.name).to eq('new_tag_name') + expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name') expect(versions.second.category).to eq('meme') expect(versions.second.created_by_user_id).to eq(member_user.id) end @@ -719,7 +730,7 @@ RSpec.describe 'Tags API', type: :request do it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') @@ -747,7 +758,7 @@ RSpec.describe 'Tags API', type: :request do it 'tag の category だけを変更しても wiki version は作成しない' do wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') @@ -774,7 +785,8 @@ RSpec.describe 'Tags API', type: :request do } expect(response).to have_http_status(:ok) - expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'spec_tag')) + .to have_attributes(tag_id: tag.id, primary_flg: false) patch "/tags/#{ tag.id }", params: { name: 'spec_tag' } @@ -782,15 +794,17 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(tag.name).to eq('spec_tag') - expect(tag.tag_name.canonical_id).to be_nil - expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name) + expect(primary_tag_name_for(tag, 'ja').name).to eq('spec_tag') + expect(primary_tag_name_for(tag, 'ja')).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: true) + expect(TagName.find_by!(language_code: 'ja', name: 'patch_roundtrip_target')) + .to have_attributes(tag_id: tag.id, primary_flg: false) end it '別 tag の正規名には変更できない' do wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'patch collision wiki', created_by_user: member_user, message: 'init') @@ -802,9 +816,10 @@ RSpec.describe 'Tags API', type: :request do 'name' => ['その名前は既に使はれてゐます.'] ) - expect(tag.reload.name).to eq('spec_tag') - expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko') - expect(wiki_page.reload.tag_name).to eq(tag.tag_name) + expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') + expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) + .to contain_exactly('unko') + expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) end it 'system tag の name は変更できない' do @@ -813,34 +828,34 @@ RSpec.describe 'Tags API', type: :request do patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' } expect(response).to have_http_status(:unprocessable_entity) - expect(system_tag.reload.name).to eq('bot操作') + expect(primary_tag_name_for(system_tag.reload, 'ja').name).to eq('bot操作') end end end describe 'GET /tags/with-depth' do let!(:root_meme) do - Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme) + create(:tag, primary_name: 'depth_a_root_meme', category: :meme) end let!(:root_material) do - Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material) + create(:tag, primary_name: 'depth_b_root_material', category: :material) end let!(:hidden_general_root) do - Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general) + create(:tag, primary_name: 'depth_hidden_general_root', category: :general) end let!(:child_character) do - Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character) + create(:tag, primary_name: 'depth_child_character', category: :character) end let!(:grandchild_material) do - Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material) + create(:tag, primary_name: 'depth_grandchild_material', category: :material) end let!(:child_general) do - Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general) + create(:tag, primary_name: 'depth_child_general', category: :general) end before do @@ -883,15 +898,13 @@ RSpec.describe 'Tags API', type: :request do end it 'passes through deprecated tags when finding children' do - deprecated_middle = Tag.create!( - name: 'depth_deprecated_middle', - category: :character, - deprecated_at: Time.current - ) - visible_descendant = Tag.create!( - name: 'depth_visible_descendant', - category: :material - ) + deprecated_middle = create(:tag, + primary_name: 'depth_deprecated_middle', + category: :character, + deprecated_at: Time.current) + visible_descendant = create(:tag, + primary_name: 'depth_visible_descendant', + category: :material) TagImplication.create!(parent_tag: root_material, tag: deprecated_middle) TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant) @@ -903,24 +916,20 @@ RSpec.describe 'Tags API', type: :request do end it 'passes through multiple deprecated tags for roots and has_children' do - active_child = Tag.create!( - name: 'depth_active_child_below_deprecated', - category: :character - ) - deprecated_parent = Tag.create!( - name: 'depth_deprecated_parent', - category: :character, - deprecated_at: Time.current - ) - deprecated_grandparent = Tag.create!( - name: 'depth_deprecated_grandparent', - category: :material, - deprecated_at: Time.current - ) - active_ancestor = Tag.create!( - name: 'depth_active_ancestor', - category: :meme - ) + active_child = create(:tag, + primary_name: 'depth_active_child_below_deprecated', + category: :character) + deprecated_parent = create(:tag, + primary_name: 'depth_deprecated_parent', + category: :character, + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + primary_name: 'depth_deprecated_grandparent', + category: :material, + deprecated_at: Time.current) + active_ancestor = create(:tag, + primary_name: 'depth_active_ancestor', + category: :meme) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) @@ -945,15 +954,13 @@ RSpec.describe 'Tags API', type: :request do end it 'treats an active tag with only deprecated ancestors as a root' do - active_child = Tag.create!( - name: 'depth_root_below_deprecated', - category: :character - ) - deprecated_parent = Tag.create!( - name: 'depth_root_deprecated_parent', - category: :material, - deprecated_at: Time.current - ) + active_child = create(:tag, + primary_name: 'depth_root_below_deprecated', + category: :character) + deprecated_parent = create(:tag, + primary_name: 'depth_root_deprecated_parent', + category: :material, + deprecated_at: Time.current) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) get '/tags/with-depth' @@ -967,16 +974,14 @@ RSpec.describe 'Tags API', type: :request do end it 'terminates when deprecated implications contain a cycle' do - first = Tag.create!( - name: 'depth_cycle_first', - category: :character, - deprecated_at: Time.current - ) - second = Tag.create!( - name: 'depth_cycle_second', - category: :material, - deprecated_at: Time.current - ) + first = create(:tag, + primary_name: 'depth_cycle_first', + category: :character, + deprecated_at: Time.current) + second = create(:tag, + primary_name: 'depth_cycle_second', + category: :material, + deprecated_at: Time.current) TagImplication.create!(tag: first, parent_tag: root_material) TagImplication.create!(tag: second, parent_tag: first) now = Time.current @@ -1002,19 +1007,19 @@ RSpec.describe 'Tags API', type: :request do let!(:material_user) { create_member_user! } let!(:root_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material) + create(:tag, primary_name: 'materials_root', category: :material) end let!(:child_a_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material) + create(:tag, primary_name: 'materials_child_a', category: :material) end let!(:child_b_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character) + create(:tag, primary_name: 'materials_child_b', category: :character) end let!(:grandchild_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material) + create(:tag, primary_name: 'materials_grandchild', category: :material) end let!(:root_material) do @@ -1036,7 +1041,7 @@ RSpec.describe 'Tags API', type: :request do end it 'returns a tag tree with nested materials sorted by child name' do - get "/tags/name/#{ CGI.escape(root_tag.name) }/materials" + get "/tags/name/#{ CGI.escape(primary_tag_name_for(root_tag, 'ja').name) }/materials" expect(response).to have_http_status(:ok) @@ -1127,7 +1132,7 @@ RSpec.describe 'Tags API', type: :request do } expect(response).to have_http_status(:unprocessable_entity) - expect(tag.reload.name).to eq('spec_tag') + expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') end it 'category が空なら 422 を返す' do @@ -1139,7 +1144,7 @@ RSpec.describe 'Tags API', type: :request do } expect(response).to have_http_status(:unprocessable_entity) - expect(tag.reload.name).to eq('spec_tag') + expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') expect(tag.category).to eq('general') end @@ -1158,14 +1163,12 @@ RSpec.describe 'Tags API', type: :request do end it 'name, category, aliases, parent tags をまとめて更新できる' do - old_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_old_parent'), - category: :general - ) - kept_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_kept_parent'), - category: :general - ) + old_parent = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_old_parent'), + category: :general) + kept_parent = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_kept_parent'), + category: :general) TagImplication.create!(tag:, parent_tag: old_parent) TagImplication.create!(tag:, parent_tag: kept_parent) @@ -1181,22 +1184,24 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(tag.name).to eq('put_renamed_tag') + expect(primary_tag_name_for(tag, 'ja').name).to eq('put_renamed_tag') expect(tag.category).to eq('meme') - expect(TagName.find_by(name: 'put_alias_a').canonical).to eq(tag.tag_name) - expect(TagName.find_by(name: 'put_alias_b').canonical).to eq(tag.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_a')) + .to have_attributes(tag_id: tag.id, primary_flg: false) + expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_b')) + .to have_attributes(tag_id: tag.id, primary_flg: false) - old_name_alias = TagName.find_by(name: 'spec_tag') + old_name_alias = TagName.find_by(language_code: 'ja', name: 'spec_tag') expect(old_name_alias).to be_present - expect(old_name_alias.canonical).to eq(tag.tag_name) + expect(old_name_alias).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: false) - expect(alias_tn.reload.canonical).to be_nil + expect(alias_tn.reload.tag_id).to be_nil - expect(tag.parents.map(&:name)).to contain_exactly( - 'put_kept_parent', - 'put_new_parent' - ) + expect(TagName.where(tag_id: tag.parents.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name)) + .to contain_exactly('put_kept_parent', 'put_new_parent') expect(TagImplication.where(tag:, parent_tag: old_parent)).not_to exist @@ -1226,7 +1231,8 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(TagName.find_by(name: 'put_alias_self_test').canonical).to eq(tag.tag_name) + expect(TagName.find_by!(language_code: 'ja', name: 'put_alias_self_test')) + .to have_attributes(tag_id: tag.id, primary_flg: false) expect(json['aliases']).to include('put_alias_self_test') expect(json['aliases']).not_to include('spec_tag') end @@ -1263,13 +1269,13 @@ RSpec.describe 'Tags API', type: :request do expect(versions.map(&:event_type)).to eq(['create', 'update']) - expect(versions.first.name).to eq('spec_tag') + expect(version_primary_name(versions.first, 'ja')).to eq('spec_tag') expect(versions.first.category).to eq('general') - expect(versions.first.aliases.split).to include('unko') + expect(version_alias_names(versions.first, 'ja')).to include('unko') - expect(versions.second.name).to eq('put_versioned_tag') + expect(version_primary_name(versions.second, 'ja')).to eq('put_versioned_tag') expect(versions.second.category).to eq('meta') - expect(versions.second.aliases.split).to include('spec_tag') + expect(version_alias_names(versions.second, 'ja')).to include('spec_tag') expect(versions.second.created_by_user_id).to eq(member_user.id) end @@ -1296,35 +1302,37 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(tag.name).to eq('spec_tag') - expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name) - expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko') - expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag') - expect(alias_tn.reload.canonical).to eq(tag.tag_name) + expect(primary_tag_name_for(tag, 'ja').name).to eq('spec_tag') + expect(TagName.find_by!(language_code: 'ja', name: 'put_roundtrip_b')) + .to have_attributes(tag_id: tag.id, primary_flg: false) + expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) + .to contain_exactly('put_roundtrip_b', 'unko') + expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) + .not_to include('spec_tag') + expect(alias_tn.reload).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: false) version = tag.tag_versions.order(:version_no).last expect(version.event_type).to eq('update') - expect(version.name).to eq('spec_tag') - expect(version.aliases.split).to contain_exactly('put_roundtrip_b', 'unko') + expect(version_primary_name(version, 'ja')).to eq('spec_tag') + expect(version_alias_names(version, 'ja')).to contain_exactly('put_roundtrip_b', 'unko') end it 'parent tag の snapshot も作成する' do - old_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_snapshot_old_parent'), - category: :general - ) - new_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_snapshot_new_parent'), - category: :general - ) + old_parent = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_snapshot_old_parent'), + category: :general) + new_parent = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_snapshot_new_parent'), + category: :general) TagImplication.create!(tag:, parent_tag: old_parent) put "/tags/#{ tag.id }", params: { name: 'spec_tag', category: 'general', aliases: 'unko', - parent_tags: new_parent.name, + parent_tags: primary_tag_name_for(new_parent, 'ja').name, deprecated: '0', } @@ -1347,13 +1355,13 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:unprocessable_entity) - expect(tag.reload.name).to eq('spec_tag') + expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') expect(tag.category).to eq('general') end it 'system tag の name は変更できない' do system_tag = Tag.tagme - old_name = system_tag.name + old_name = primary_tag_name_for(system_tag, 'ja').name old_category = system_tag.category expect { @@ -1368,14 +1376,14 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:unprocessable_entity) - expect(system_tag.reload.name).to eq(old_name) + expect(primary_tag_name_for(system_tag.reload, 'ja').name).to eq(old_name) expect(system_tag.category).to eq(old_category) end it 'wiki を持つ tag を更新すると wiki version も作成する' do wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') @@ -1411,10 +1419,10 @@ RSpec.describe 'Tags API', type: :request do ) end - it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do + it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') @@ -1443,9 +1451,11 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(wiki_page.reload.tag_name).to eq(tag.tag_name) - expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil - expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name) + expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) + expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b').wiki_page) + .to be_nil + expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) + .to have_attributes(tag_id: tag.id, primary_flg: false) versions = wiki_page.wiki_versions.order(:version_no).last(2) @@ -1454,16 +1464,16 @@ RSpec.describe 'Tags API', type: :request do end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do - old_owner = Tag.create!( - tag_name: TagName.create!(name: 'put_alias_old_owner'), - category: :general - ) - stolen_alias = TagName.create!( - name: 'put_stolen_alias', - canonical: old_owner.tag_name - ) + old_owner = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_alias_old_owner'), + category: :general) + stolen_alias = create(:tag_name, :alias, + name: 'put_stolen_alias', + tag: old_owner) - expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') + expect(TagName.where(tag_id: old_owner.id, language_code: 'ja', + primary_flg: false).pluck(:name)) + .to include('put_stolen_alias') expect { put "/tags/#{ tag.id }", params: { @@ -1479,30 +1489,32 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) - expect(stolen_alias.reload.canonical).to eq(tag.tag_name) - expect(old_owner.reload.tag_name.aliases.map(&:name)).not_to include('put_stolen_alias') + expect(stolen_alias.reload).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: false) + expect(TagName.where(tag_id: old_owner.id, language_code: 'ja', + primary_flg: false).pluck(:name)) + .not_to include('put_stolen_alias') old_owner_versions = old_owner.tag_versions.order(:version_no) expect(old_owner_versions.first.event_type).to eq('create') - expect(old_owner_versions.first.aliases.split).to include('put_stolen_alias') + expect(version_alias_names(old_owner_versions.first, 'ja')).to include('put_stolen_alias') expect(old_owner_versions.second.event_type).to eq('update') - expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias') + expect(version_alias_names(old_owner_versions.second, 'ja')) + .not_to include('put_stolen_alias') end it '別 tag の alias 名を rename で奪へる' do - old_owner = Tag.create!( - tag_name: TagName.create!(name: 'put_alias_collision_owner'), - category: :general - ) - stolen_alias = TagName.create!( - name: 'put_alias_collision_name', - canonical: old_owner.tag_name - ) + old_owner = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_alias_collision_owner'), + category: :general) + stolen_alias = create(:tag_name, :alias, + name: 'put_alias_collision_name', + tag: old_owner) wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'put collision wiki', created_by_user: member_user, message: 'init') @@ -1515,29 +1527,33 @@ RSpec.describe 'Tags API', type: :request do deprecated: '0', } - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:ok), response.body tag.reload old_owner.reload stolen_alias.reload - expect(tag.name).to eq('put_alias_collision_name') - expect(stolen_alias.canonical_id).to be_nil - expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name) - expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name') + expect(primary_tag_name_for(tag, 'ja').name).to eq('put_alias_collision_name') + expect(stolen_alias).to have_attributes( + tag_id: tag.id, language_code: 'ja', primary_flg: true) + expect(TagName.find_by!(language_code: 'ja', name: 'spec_tag')) + .to have_attributes(tag_id: tag.id, primary_flg: false) + expect(TagName.where(tag_id: old_owner.id, language_code: 'ja', + primary_flg: false).pluck(:name)) + .not_to include('put_alias_collision_name') old_owner_versions = old_owner.tag_versions.order(:version_no) expect(old_owner_versions.last.event_type).to eq('update') - expect(old_owner_versions.last.aliases.split).not_to include('put_alias_collision_name') - expect(wiki_page.reload.tag_name).to eq(tag.tag_name) + expect(version_alias_names(old_owner_versions.last, 'ja')) + .not_to include('put_alias_collision_name') + expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) end it 'parent_tags に指定すると循環する tag は 422 にする' do - child = Tag.create!( - tag_name: TagName.create!(name: 'put_cycle_child'), - category: :general - ) + child = create(:tag, + primary_tag_name: create(:tag_name, name: 'put_cycle_child'), + category: :general) TagImplication.create!(tag: child, parent_tag: tag) @@ -1545,7 +1561,7 @@ RSpec.describe 'Tags API', type: :request do name: 'spec_tag', category: 'general', aliases: 'unko', - parent_tags: child.name, + parent_tags: primary_tag_name_for(child, 'ja').name, deprecated: '0', } @@ -1557,7 +1573,7 @@ RSpec.describe 'Tags API', type: :request do it 'tag の name を変更すると対応する wiki version を作成する' do wiki_page = Wiki::Commit.create_content!( - tag_name: tag.tag_name, + tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 7fd3a0a..60fb964 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -396,7 +396,7 @@ RSpec.describe 'Theatres API', type: :request do end it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do - tag = create(:tag, name: 'skip-target') + tag = create(:tag, primary_name: 'skip-target') PostTag.create!(post: niconico_post, tag:) TheatreSkipVote.create!(theatre:, post: niconico_post, user: member) @@ -503,7 +503,7 @@ RSpec.describe 'Theatres API', type: :request do end it 'returns tag penalties and candidate weights for the current watchers' do - tag = create(:tag, name: 'heavy-tag') + tag = create(:tag, primary_name: 'heavy-tag') PostTag.create!(post: second_niconico_post, tag:) event = TheatreSkipEvent.create!( theatre:, diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 9ae9052..39c5458 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do it 'returns 409 when base_revision_id is stale' do page = Wiki::Commit.create_content!( - tag_name: TagName.create!(name: 'wiki_conflict_request'), + tag_name: create(:tag_name, name: 'wiki_conflict_request'), body: 'first', created_by_user: user, message: 'init') diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb index 7f2ccce..db365c6 100644 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'Wiki history integrity', type: :request do def create_wiki_page title:, body: 'body', message: 'init', user: self.user Wiki::Commit.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message:) diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 614180e..e1d31b1 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'Wiki API', type: :request do let!(:user) { create_member_user! } - let!(:tn) { TagName.create!(name: 'spec_wiki_title') } + let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } let!(:page) do Wiki::Commit.create_content!( tag_name: tn, @@ -19,11 +19,10 @@ RSpec.describe 'Wiki API', type: :request do message: 'init') end let!(:tag) do - Tag.create!( - tag_name: tn, - category: :general, - deprecated_at: Time.zone.local(2026, 6, 1) - ) + create(:tag, + primary_tag_name: tn, + category: :general, + deprecated_at: Time.zone.local(2026, 6, 1)) end describe 'GET /wiki' do @@ -208,7 +207,7 @@ RSpec.describe 'Wiki API', type: :request do { 'X-Transfer-Code' => user.inheritance_code } end - let!(:test_tag_name) { TagName.create!(name: 'TestPage') } + let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } let!(:page) do Wiki::Commit.create_content!( @@ -275,8 +274,8 @@ RSpec.describe 'Wiki API', type: :request do end it 'wiki body だけを変更しても tag version は作成しない' do - linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') - linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + linked_tag_name = create(:tag_name, name: 'wiki_body_only_tag') + linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) TagVersionRecorder.record!( tag: linked_tag, @@ -359,13 +358,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' do before do Wiki::Commit.create_content!( - tag_name: TagName.create!(name: 'spec_wiki_title_2'), + tag_name: create(:tag_name, name: 'spec_wiki_title_2'), body: 'search body 2', created_by_user: user, message: 'init') Wiki::Commit.create_content!( - tag_name: TagName.create!(name: 'unrelated_title'), + tag_name: create(:tag_name, name: 'unrelated_title'), body: 'unrelated body', created_by_user: user, message: 'init') @@ -434,7 +433,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し - tn2 = TagName.create!(name: 'spec_no_rev') + tn2 = create(:tag_name, name: 'spec_no_rev') # 異常データ: revision 無し WikiPage を直接作る p2 = WikiPage.create!( tag_name: tn2, @@ -514,7 +513,7 @@ RSpec.describe 'Wiki API', type: :request do describe 'Wiki::Commit.redirect!' do it 'raises because redirect revisions are deprecated' do - target_tag_name = TagName.create!(name: 'redirect_deprecated_target') + target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target = Wiki::Commit.create_content!( tag_name: target_tag_name, @@ -535,8 +534,8 @@ RSpec.describe 'Wiki API', type: :request do end it 'wiki title を変更すると対応する tag の version を作成する' do - linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') - linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version') + linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( @@ -564,7 +563,8 @@ RSpec.describe 'Wiki API', type: :request do expect(response).to have_http_status(:ok) linked_tag.reload - expect(linked_tag.name).to eq('wiki_linked_tag_for_version_renamed') + expect(primary_tag_name_for(linked_tag, 'ja').name) + .to eq('wiki_linked_tag_for_version_renamed') versions = linked_tag.tag_versions.order(:version_no) diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index ad02dfa..f78f987 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do def create_wiki_page title:, body: Wiki::Commit.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message: 'init') diff --git a/backend/spec/services/material_sync_importer_spec.rb b/backend/spec/services/material_sync_importer_spec.rb index 20c69fd..b2734e3 100644 --- a/backend/spec/services/material_sync_importer_spec.rb +++ b/backend/spec/services/material_sync_importer_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe MaterialSyncImporter do let(:user) { create(:user, :member) } - let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) } + let(:tag) { create(:tag, primary_name: 'sync_tag', category: :material) } def tempfile_for body Tempfile.new(['material-sync-importer', '.png']).tap do |file| diff --git a/backend/spec/services/nico_tag_version_recorder_spec.rb b/backend/spec/services/nico_tag_version_recorder_spec.rb index e90d1e6..e48745b 100644 --- a/backend/spec/services/nico_tag_version_recorder_spec.rb +++ b/backend/spec/services/nico_tag_version_recorder_spec.rb @@ -65,7 +65,7 @@ RSpec.describe NicoTagVersionRecorder do end it 'records sorted linked internal names and later link removal' do - tags = ['z_link', 'a_link'].map { |name| Tag.create!(name:, category: :general) } + tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) } tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) } expect(record(:create).linked_tags).to eq('a_link z_link') diff --git a/backend/spec/services/post_bulk_creator_spec.rb b/backend/spec/services/post_bulk_creator_spec.rb index 0779661..d5167bb 100644 --- a/backend/spec/services/post_bulk_creator_spec.rb +++ b/backend/spec/services/post_bulk_creator_spec.rb @@ -1,7 +1,10 @@ require 'rails_helper' RSpec.describe PostBulkCreator do + include_context 'English locale' + it 'limits workers to two and keeps failures in their request slots' do + locale = Locale.nipponese actor = instance_double(User, id: 123) allow(User).to receive(:find).with(123) { instance_double(User, id: 123) @@ -9,6 +12,7 @@ RSpec.describe PostBulkCreator do mutex = Mutex.new active = 0 maximum_active = 0 + creators = [] allow(PostCreatePreflight).to receive(:new) do |attributes:, **| preflight = instance_double(PostCreatePreflight) @@ -28,6 +32,7 @@ RSpec.describe PostBulkCreator do end allow(PostCreator).to receive(:new) do |attributes:, **| creator = instance_double(PostCreator) + mutex.synchronize { creators << creator } if attributes[:title] == 'broken' allow(creator).to receive(:create!).and_raise(StandardError, 'broken') else @@ -45,7 +50,12 @@ RSpec.describe PostBulkCreator do results = described_class.new( actor:, posts:, - thumbnails: { }).run.fetch(:results) + thumbnails: { }).run(locale).fetch(:results) + + expect(creators.length).to eq(posts.length) + creators.each do |creator| + expect(creator).to have_received(:create!).with(locale).once + end expect(maximum_active).to eq(2) expect(results.length).to eq(posts.length) @@ -56,4 +66,26 @@ RSpec.describe PostBulkCreator do expect(results[2].dig(:post, :id)).to eq(3) expect(results[3].dig(:post, :id)).to eq(4) end + + it 'passes a future English locale to preflight and creation in every worker' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + actor = instance_double(User, id: 123) + allow(User).to receive(:find).with(123).and_return(actor) + preflight = instance_double(PostCreatePreflight) + creator = instance_double(PostCreator) + post = instance_double(Post, id: 1) + allow(PostCreatePreflight).to receive(:new).and_return(preflight) + allow(preflight).to receive(:run).with(locale).and_return(existing_post_id: nil) + allow(PostCreator).to receive(:new).and_return(creator) + allow(creator).to receive(:create!).with(locale).and_return(post) + + results = described_class.new( + actor:, posts: [{ title: 'future post' }], thumbnails: { }).run(locale) + + expect(results.fetch(:results)).to contain_exactly( + hash_including(status: 'created', post: { id: 1 })) + expect(preflight).to have_received(:run).with(locale).once + expect(creator).to have_received(:create!).with(locale).once + end end diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 71a19c1..7d190ab 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe PostCreatePlan do def create_tag! name, category - Tag.create!(name:, category:) + create(:tag, primary_name: name, category:) end before do @@ -10,6 +10,63 @@ RSpec.describe PostCreatePlan do create_tag!('ニジラー情報不詳', :meta) end + context 'with an explicit locale' do + include_context 'English locale' + + it 'resolves the matching language identity and its parents' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_name = create(:tag_name, name: 'shared_name', + language_code: 'en', script_code: 'Latn') + english = create(:tag, primary_tag_name: english_name, category: :character) + nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general) + # The DB permits this identity; validation has its own contract spec. + primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name') + english_parent_name = create(:tag_name, name: 'english_parent', + language_code: 'en', script_code: 'Latn') + english_parent = create(:tag, primary_tag_name: english_parent_name, + category: :material) + nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general) + TagImplication.create!(tag: english, parent_tag: english_parent) + TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent) + + plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale) + + expect(plan[:direct_tag_specs]).to eq( + [{ name: 'shared_name', category: :character }]) + expect(plan[:post_tag_specs]).to include( + { name: primary_tag_name_for(english_parent, 'en').name, category: :material }) + expect(plan[:post_tag_specs].pluck(:name)) + .not_to include(primary_tag_name_for(nipponese_parent, 'ja').name) + end + + it 'resolves aliases within the requested language' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + nipponese = create(:tag, primary_name: 'nipponese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, primary_tag_name: english_name, category: :character) + nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: nipponese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, + language_code: 'en', script_code: 'Latn') + + expect(nipponese_alias.reload).to have_attributes( + tag_id: nipponese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + tag_id: english.id, + primary_flg: false, language_code: 'en') + + plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) + + expect(plan[:direct_tag_specs]).to eq( + [{ name: english_name.name, category: :character }]) + end + end + it 'plans direct and existing default tags without persisting records' do counts = [TagName.count, Tag.count] @@ -18,7 +75,7 @@ RSpec.describe PostCreatePlan do url: 'https://example.com/post', title: 'title', tags: 'character:new_character', - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) expect(plan[:tags]).to eq('new_character') expect(plan[:direct_tag_specs]).to eq( @@ -29,9 +86,9 @@ RSpec.describe PostCreatePlan do expect([TagName.count, Tag.count]).to eq(counts) end - it 'resolves aliases and keeps tag sections separate from canonical names' do - canonical = create_tag!('虹夏', :character) - TagName.create!(name: 'にじか', canonical: canonical.tag_name) + it 'resolves aliases and keeps tag sections separate from primary names' do + tag = create_tag!('虹夏', :character) + create(:tag_name, :alias, name: 'にじか', tag:) create_tag!('動画', :meta) plan = described_class.new( @@ -40,7 +97,7 @@ RSpec.describe PostCreatePlan do title: 'video', tags: '動画 にじか[0:10-0:20]', duration: '1:00', - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]') expect(plan[:display_tags]).to include( @@ -55,7 +112,7 @@ RSpec.describe PostCreatePlan do counts = [Tag.count, TagName.count, ExternalTag.count] expect { - described_class.new(attributes: { tags: 'NiCo:reserved' }).build! + described_class.new(attributes: { tags: 'NiCo:reserved' }).build!(Locale.nipponese) }.to raise_error(ActiveRecord::RecordInvalid) { |error| expect(error.record.errors[:tags]).to be_present } @@ -73,7 +130,7 @@ RSpec.describe PostCreatePlan do url: 'https://example.com/post', title: 'title', tags: long_name, - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) }.to raise_error(ActiveRecord::RecordInvalid) { |error| expect(error.record.errors[:tags]).not_to be_empty } @@ -87,7 +144,7 @@ RSpec.describe PostCreatePlan do title: 'title', tags: 'ordinary_tag', duration: 'invalid', - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) expect(plan[:duration]).to eq('invalid') expect(plan[:video_ms]).to be_nil diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb index cadf8a1..624577d 100644 --- a/backend/spec/services/post_creator_spec.rb +++ b/backend/spec/services/post_creator_spec.rb @@ -14,7 +14,6 @@ RSpec.describe PostCreator do end before do - allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} }) allow(TagVersioning).to receive(:record_tag_snapshots!) allow(Tag).to receive(:expand_parent_tags).and_return([]) allow(PostVersionRecorder).to receive(:record!) @@ -34,7 +33,7 @@ RSpec.describe PostCreator do url: 'https://example.com/post', thumbnail: real_thumbnail_upload, thumbnail_base: 'https://example.com/thumb.jpg', - tags: '' }).create! + tags: '' }).create!(Locale.nipponese) expect(post.thumbnail).to be_attached expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg') @@ -54,7 +53,7 @@ RSpec.describe PostCreator do title: 'title', url: 'https://example.com/post', thumbnail_base: 'https://example.com/thumb.jpg', - tags: '' }).create! + tags: '' }).create!(Locale.nipponese) expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg') expect(post.thumbnail).to be_attached @@ -72,7 +71,36 @@ RSpec.describe PostCreator do tags: '' }) post_count = Post.count - expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed) + expect { creator.create!(Locale.nipponese) } + .to raise_error(Post::RemoteThumbnailFetchFailed) expect(Post.count).to eq(post_count) end + + context 'with an explicit locale' do + include_context 'English locale' + + [false, true].each do |planned| + it "passes locale to tag creation with planned attributes: #{ planned }" do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + attributes = { + title: 'Locale propagation', + url: 'https://example.com/locale-post', + tags: 'character:locale_character' } + if planned + specs = [{ name: 'locale_character', category: :character }] + attributes.merge!(snapshot_tag_specs: specs, post_tag_specs: specs) + end + allow(Tag).to receive(:find_or_create_by_tag_name!).and_call_original + + post = described_class.new(actor:, attributes:).create!(locale) + + expect(Tag).to have_received(:find_or_create_by_tag_name!) + .with(locale, 'locale_character', category: :character).at_least(:once) + tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character') + expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true) + expect(post.tags).to include(tag_name.tag) + end + end + end end diff --git a/backend/spec/services/post_import_previewer_spec.rb b/backend/spec/services/post_import_previewer_spec.rb index bd6e267..cb6888b 100644 --- a/backend/spec/services/post_import_previewer_spec.rb +++ b/backend/spec/services/post_import_previewer_spec.rb @@ -64,7 +64,7 @@ RSpec.describe PostImportPreviewer do end it 'applies metadata to automatic fields and recognises metadata tags' do - Tag.create!(name: 'known-tag', category: :general) + create(:tag, primary_name: 'known-tag', category: :general) allow(PostMetadataFetcher).to receive(:fetch).and_return( title: 'metadata title', thumbnail_base: 'https://example.com/thumb.jpg', diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb new file mode 100644 index 0000000..e403ef6 --- /dev/null +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe TagVersionRecorder do + include_context 'English locale' + + let(:user) { create_member_user! } + let!(:tag) { create(:tag, primary_name: '日本語名') } + + before do + create(:tag_name, :alias, tag:, name: 'alias_z') + create(:tag_name, :alias, tag:, name: 'alias_a') + end + + def nipponese_snapshot + { 'name' => '日本語名', 'script_code' => 'Jpan', 'auto_generated' => false, + 'aliases' => [{ 'name' => 'alias_a', 'script_code' => 'Jpan' }, + { 'name' => 'alias_z', 'script_code' => 'Jpan' }] } + end + + def prepare_english_names! + locale + primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false) + create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn', + name: 'English alias') + end + + it 'snapshots the Nipponese primary and sorted aliases in grouped names' do + version = described_class.record!(tag:, event_type: :create, created_by_user: user) + + expect(version.reload.names).to eq('ja' => nipponese_snapshot) + end + + it 'does not create a version for an unchanged Nipponese snapshot' do + original = described_class.record!(tag:, event_type: :create, created_by_user: user) + + unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user) + + expect(unchanged.version_no).to eq(original.version_no) + expect(tag.reload.tag_versions.count).to eq(1) + end + + it 'snapshots an unconfirmed system primary with auto_generated true' do + primary_tag_name_for(tag, 'ja').update!(auto_generated: true) + + version = described_class.record!(tag:, event_type: :create, created_by_user: user) + + expect(version.names.fetch('ja')).to eq(nipponese_snapshot.merge('auto_generated' => true)) + end + + it 'keeps a past snapshot after current primary and alias changes' do + original = described_class.record!(tag:, event_type: :create, created_by_user: user) + primary_tag_name_for(tag, 'ja').update!(name: '改名後') + tag.tag_names.find_by!(name: 'alias_z').destroy! + + updated = described_class.record!(tag:, event_type: :update, created_by_user: user) + + expect(original.reload.names).to eq('ja' => nipponese_snapshot) + expect(updated.names.fetch('ja')).to include('name' => '改名後') + expect(updated.names.fetch('ja').fetch('aliases')) + .to eq([{ 'name' => 'alias_a', 'script_code' => 'Jpan' }]) + expect(updated.version_no).to eq(2) + end + + it 'snapshots future languages together without a request locale argument' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_names! + + version = described_class.record!(tag:, event_type: :create, created_by_user: user) + + expect(version.names).to eq( + 'ja' => nipponese_snapshot, + 'en' => { 'name' => 'English name', 'script_code' => 'Latn', + 'auto_generated' => false, + 'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] }) + end + + it 'creates a version for an English-only change and preserves prior names' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_names! + original = described_class.record!(tag:, event_type: :create, created_by_user: user) + primary_tag_name_for(tag, 'en').update!(name: 'Renamed') + + updated = described_class.record!(tag:, event_type: :update, created_by_user: user) + + expect(updated.version_no).to eq(2) + expect(original.reload.names.fetch('en')).to include('name' => 'English name') + expect(updated.names.fetch('en')).to include('name' => 'Renamed') + expect(updated.names.fetch('ja')).to eq(nipponese_snapshot) + end + + it 'does not create a version for an unchanged multilingual snapshot' do + pending '日本語以外の Locale 有効化は別課題で実装する' + prepare_english_names! + described_class.record!(tag:, event_type: :create, created_by_user: user) + + unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user) + + expect(unchanged.version_no).to eq(1) + expect(tag.reload.tag_versions.count).to eq(1) + end +end diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb index 4a08de5..42c42b7 100644 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Wiki::Commit do def create_page title:, body: 'initial body' described_class.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message: 'init') @@ -16,7 +16,7 @@ RSpec.describe Wiki::Commit do it 'creates page, revision, and version with normalised body' do expect { described_class.create_content!( - tag_name: TagName.create!(name: 'commit_integrity_create'), + tag_name: create(:tag_name, name: 'commit_integrity_create'), body: "a\r\nb\r\n\r\n", created_by_user: user, message: 'init') @@ -36,7 +36,7 @@ RSpec.describe Wiki::Commit do end it 'rejects body that becomes blank after normalisation' do - tag_name = TagName.create!(name: 'commit_integrity_blank') + tag_name = create(:tag_name, name: 'commit_integrity_blank') expect { described_class.create_content!( @@ -76,8 +76,8 @@ RSpec.describe Wiki::Commit do end it 'does not record tag_version on body-only wiki update' do - tag_name = TagName.create!(name: 'commit_integrity_linked_tag') - tag = Tag.create!(tag_name:, category: :general) + tag_name = create(:tag_name, name: 'commit_integrity_linked_tag') + tag = create(:tag, primary_tag_name: tag_name, category: :general) page = described_class.create_content!( diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb index 2e564cf..122ac58 100644 --- a/backend/spec/services/wiki/commit_spec.rb +++ b/backend/spec/services/wiki/commit_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Wiki::Commit do let(:user) { create_member_user! } def create_page(title: 'commit_spec_page', body: 'initial body') - tag_name = TagName.create!(name: title) + tag_name = create(:tag_name, name: title) Wiki::Commit.create_content!( tag_name:, @@ -80,8 +80,8 @@ RSpec.describe Wiki::Commit do end it 'does not record tag version when corresponding tag has no versions' do - tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') - tag = Tag.create!(tag_name:, category: :general) + tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') + tag = create(:tag, primary_tag_name: tag_name, category: :general) page = described_class.create_content!( @@ -107,8 +107,8 @@ RSpec.describe Wiki::Commit do end it 'does not record tag version when corresponding tag has no versions' do - tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') - tag = Tag.create!(tag_name:, category: :general) + tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') + tag = create(:tag, primary_tag_name: tag_name, category: :general) page = described_class.create_content!( diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb index 9f1c6dd..c8c4dfa 100644 --- a/backend/spec/services/wiki_version_recorder_spec.rb +++ b/backend/spec/services/wiki_version_recorder_spec.rb @@ -5,7 +5,7 @@ RSpec.describe WikiVersionRecorder do def create_page title:, body: 'body' Wiki::Commit.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message: 'init') diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb index c24c6a1..e8d7a24 100644 --- a/backend/spec/services/youtube/sync_spec.rb +++ b/backend/spec/services/youtube/sync_spec.rb @@ -172,7 +172,8 @@ RSpec.describe Youtube::Sync do Tag.video Tag.no_deerjikist - deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist) + deerjikist_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'テスト投稿者', category: :deerjikist) Deerjikist.create!( platform: 'youtube', code: 'UC_MAPPED', @@ -226,7 +227,8 @@ RSpec.describe Youtube::Sync do ) PostTag.create!(post:, tag: Tag.no_deerjikist) - deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist) + deerjikist_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, '後から判明した投稿者', category: :deerjikist) Deerjikist.create!( platform: 'youtube', code: 'UC_MAPPED_LATER', diff --git a/backend/spec/support/locale_context.rb b/backend/spec/support/locale_context.rb new file mode 100644 index 0000000..3241f11 --- /dev/null +++ b/backend/spec/support/locale_context.rb @@ -0,0 +1,22 @@ +RSpec.shared_context 'English locale' do + def prepare_english_reference! + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + region_class = Class.new(ApplicationRecord) { self.table_name = 'regions' } + region_class.find_or_create_by!(code: 826) do |record| + record.alpha_code = 'GB' + record.name = 'United Kingdom' + end + end + + let(:locale) do + prepare_english_reference! + Locale.find_or_create_by!(code: 'en') do |record| + record.language_code = 'en' + record.script_code = 'Latn' + record.region_code = 826 + record.variants = '' + record.name = 'English' + end + end +end diff --git a/backend/spec/support/test_records.rb b/backend/spec/support/test_records.rb index b6a7cd5..a814e68 100644 --- a/backend/spec/support/test_records.rb +++ b/backend/spec/support/test_records.rb @@ -1,4 +1,8 @@ module TestRecords + def primary_tag_name_for(tag, language_code) + TagName.find_by!(tag_id: tag.id, language_code:, primary_flg: true) + end + def create_member_user! User.create!(name: 'spec user', inheritance_code: SecureRandom.hex(16), diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index b3c053f..db6d55a 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'nico:sync' do end def create_tag!(name, category:) - Tag.find_or_create_by_tag_name!(name, category:) + Tag.find_or_create_by_tag_name!(Locale.nipponese, name, category:) end def link_nico_to_tag!(nico_tag, tag) @@ -16,17 +16,22 @@ RSpec.describe 'nico:sync' do end def create_nico_sanitisation_rules! - TagNameSanitisationRule.create!(priority: 20, - source_pattern: '\\?', - replacement: '_') + rules = [ + [20, '\\?', '_'], + [40, '_$', ''], + [45, '^([^:]+\\:)?_', '\\1']] - TagNameSanitisationRule.create!(priority: 40, - source_pattern: '_$', - replacement: '') + priorities = rules.map(&:first) + source_patterns = rules.map { _1[1] } - TagNameSanitisationRule.create!(priority: 45, - source_pattern: '^([^:]+\\:)?_', - replacement: '\\1') + scope = TagNameSanitisationRule.unscoped + scope.where(priority: priorities) + .or(scope.where(source_pattern: source_patterns)) + .delete_all + + rules.each do |priority, source_pattern, replacement| + TagNameSanitisationRule.create!(priority:, source_pattern:, replacement:) + end end it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do @@ -64,7 +69,8 @@ RSpec.describe 'nico:sync' do run_rake_task('nico:sync') post.reload - active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name') + active_tag_names = TagName.where(tag_id: post.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name) expect(active_tag_names).to include('spec_kept') expect(post.external_tags).to contain_exactly(nico) @@ -434,7 +440,9 @@ RSpec.describe 'nico:sync' do external = post.external_tags.sole expect(external.name).to eq('raw tag[]') - expect(post.tags.map(&:name)).not_to include('bot操作') + expect(TagName.where(tag_id: post.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name)) + .not_to include('bot操作') expect(post.post_versions.order(:version_no).last.tags_json) .to include('external_tag_id' => external.id) @@ -446,7 +454,9 @@ RSpec.describe 'nico:sync' do run_nico_sync_with_tags!([]) }.to change(PostVersion, :count).by(1) expect(post.reload.external_tags).to be_empty - expect(post.tags.map(&:name)).not_to include('bot操作') + expect(TagName.where(tag_id: post.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name)) + .not_to include('bot操作') end it 'サニタイズ後の既存外部タグを再利用し、その連携タグを記載する' do @@ -563,7 +573,9 @@ RSpec.describe 'nico:sync' do name: 'AAA') expect(ExternalTag.exists?(platform: :nico, name: 'AAA?')).to be(false) - expect(post.tags.map(&:name)).not_to include('bot操作') + expect(TagName.where(tag_id: post.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name)) + .not_to include('bot操作') expect(post.post_versions.order(:version_no).last.tags_json) .to include('external_tag_id' => external.id) @@ -577,7 +589,9 @@ RSpec.describe 'nico:sync' do }.to change(PostVersion, :count).by(1) expect(post.reload.external_tags).to be_empty - expect(post.tags.map(&:name)).not_to include('bot操作') + expect(TagName.where(tag_id: post.tags.select(:id), + language_code: 'ja', primary_flg: true).pluck(:name)) + .not_to include('bot操作') end it 'nico: prefix を含む従来の規則で外部タグ名をサニタイズする' do diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb index 20dd575..721be6c 100644 --- a/backend/spec/tasks/post_similarity_calc_spec.rb +++ b/backend/spec/tasks/post_similarity_calc_spec.rb @@ -6,10 +6,10 @@ RSpec.describe 'post_similarity:calc' do it 'calculates similarities from active tags only' do # 必要最低限のデータ - t1 = Tag.create!(name: "t1") - t2 = Tag.create!(name: "t2") - t3 = Tag.create!(name: "t3") - deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) + t1 = create(:tag, primary_name: 't1') + t2 = create(:tag, primary_name: 't2') + t3 = create(:tag, primary_name: 't3') + deprecated_tag = create(:tag, primary_name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2") diff --git a/backend/spec/tasks/tag_similarity_calc_spec.rb b/backend/spec/tasks/tag_similarity_calc_spec.rb index 3063010..a5aae72 100644 --- a/backend/spec/tasks/tag_similarity_calc_spec.rb +++ b/backend/spec/tasks/tag_similarity_calc_spec.rb @@ -6,10 +6,10 @@ RSpec.describe 'tag_similarity:calc' do it 'calculates similarities for active tags only' do # 必要最低限のデータ - t1 = Tag.create!(name: "t1") - t2 = Tag.create!(name: "t2") - t3 = Tag.create!(name: "t3") - deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) + t1 = create(:tag, primary_name: 't1') + t2 = create(:tag, primary_name: 't2') + t3 = create(:tag, primary_name: 't3') + deprecated_tag = create(:tag, primary_name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2")