From 20085d50176321458bcbe546f24888c4985f6ef4 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 18 Oct 2026 15:36:09 +0900 Subject: [PATCH] #428 --- backend/app/models/locale.rb | 6 +- .../certified_tag_name_translations.rb | 103 ++++++++++++++++++ backend/app/services/tag_name_completion.rb | 53 +++++++-- 3 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 backend/app/services/certified_tag_name_translations.rb diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index d03e5df..f9d9951 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -1,4 +1,6 @@ class Locale < ApplicationRecord + attr_accessor :certified_tag_names_path + before_save :lock_tag_creation!, if: :will_generate_tag_names? after_save :generate_tag_names!, if: :needs_tag_names? @@ -15,9 +17,7 @@ class Locale < ApplicationRecord deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?) end - def lock_tag_creation! - SharedResourceIdAllocator.lock_space!(Tag, 'tags') - end + def lock_tag_creation! = SharedResourceIdAllocator.lock_space!(Tag, 'tags') def needs_tag_names? deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?) diff --git a/backend/app/services/certified_tag_name_translations.rb b/backend/app/services/certified_tag_name_translations.rb new file mode 100644 index 0000000..c0e6121 --- /dev/null +++ b/backend/app/services/certified_tag_name_translations.rb @@ -0,0 +1,103 @@ +require 'json' +require 'pathname' + + +class CertifiedTagNameTranslations + FORMAT = 'btrc-hub.certified-tag-name-translations.v1'.freeze + + class Error < ArgumentError + ; + end + + def self.load! path, locale: + new(path, locale:).load! + end + + def initialize path, locale: + @path = path + @locale = locale + end + + def load! + document = read_document + unless document.is_a?(Hash) && document['format'] == FORMAT + fail!('invalid format or version') + end + + source_code = document['source_locale'] + target_code = document['target_locale'] + unless target_code.is_a?(String) && target_code == Locale.codes.fetch(@locale.code) + fail!("target_locale #{ target_code.inspect } does not match the new Locale") + end + unless source_code.is_a?(String) && Locale.codes.value?(source_code) + fail!("invalid source_locale #{ source_code.inspect }") + end + source_locale = Locale.find_by(code: source_code) + fail!("source Locale #{ source_code.inspect } does not exist") unless source_locale + fail!('source_locale and target_locale must differ') if source_code == target_code + + entries = validate_entries(document['entries'], source_code, target_code) + resolve_tags(entries, source_locale.language_code) + end + + private + + def read_document + unless (@path.is_a?(String) && !@path.include?("\0") && + Pathname.new(@path).absolute?) + fail!('certified_tag_names_path must be an absolute file path') + end + fail!('JSON file does not exist or is not a regular file') unless File.file?(@path) + + text = File.read(@path, encoding: Encoding::UTF_8) + fail!('JSON file is not valid UTF-8') unless text.valid_encoding? + JSON.parse(text) + rescue JSON::ParserError, SystemCallError, IOError, EncodingError => error + fail!("cannot read JSON: #{ error.message }") + end + + def validate_entries entries, source_code, target_code + fail!('entries must be an array') unless entries.is_a?(Array) + + sources = {} + targets = {} + entries.each_with_index.map do |entry, index| + fail!("entry #{ index + 1 } must be an object") unless entry.is_a?(Hash) + source = entry[source_code] + target = entry[target_code] + unless source.is_a?(String) && source.present? && target.is_a?(String) && target.present? + fail!("entry #{ index + 1 }: missing or empty #{ source_code }/#{ target_code } name") + end + fail!("duplicate source name #{ source.inspect }") if sources.key?(source) + fail!("duplicate target name #{ target.inspect }") if targets.key?(target) + + sources[source] = true + targets[target] = true + [source, target] + end + end + + def resolve_tags entries, source_language + source_names = entries.map(&:first) + # SQL may use a case/accent-insensitive collation; the Ruby keys are exact. + matches = TagName.where(language_code: source_language, primary_flg: true, + name: source_names).pluck(:name, :tag_id).group_by(&:first) + translations = {} + entries.each do |source, target| + rows = matches.fetch(source, []) + unless rows.length == 1 + fail!("source primary name #{ source.inspect } must match exactly one tag") + end + tag_id = rows.first.last + if translations.key?(tag_id) + fail!("multiple source primary names resolve to Tag##{ tag_id }: #{ source.inspect }") + end + translations[tag_id] = target + end + translations + end + + def fail! message + raise Error, "Certified tag names #{@path.inspect}: #{ message }" + end +end diff --git a/backend/app/services/tag_name_completion.rb b/backend/app/services/tag_name_completion.rb index 77b627d..b9fec45 100644 --- a/backend/app/services/tag_name_completion.rb +++ b/backend/app/services/tag_name_completion.rb @@ -3,6 +3,16 @@ class TagNameCompletion def for_locale! locale completed = Tag.transaction do SharedResourceIdAllocator.lock_space!(Tag, 'tags') + certified_names = + if locale.certified_tag_names_path.nil? + { } + else + CertifiedTagNameTranslations.load!(locale.certified_tag_names_path, locale:) + end + Tag.where(id: certified_names.keys).lock.find_each(order: :asc) do |tag| + for_tag!(tag, locales: [locale], + certified_names: { locale.language_code => certified_names.fetch(tag.id) }) + end named_tags = TagName.where(language_code: locale.language_code, primary_flg: true) .select(:tag_id) @@ -16,7 +26,7 @@ class TagNameCompletion end end - def for_tag! tag, locales: nil + def for_tag! tag, locales: nil, certified_names: { } locales ||= Locale.where(deprecated_at: nil) .order(:language_code, :created_at, :script_code).lock @@ -24,6 +34,15 @@ class TagNameCompletion SharedResourceIdAllocator.lock_space!(Tag, 'tags') completed = tag.with_lock do primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code) + certified_names.each do |language, name| + primary = primary_names[language] + next unless primary + next if primary.name == name && !primary.auto_generated + + raise CertifiedTagNameTranslations::Error, + "Tag##{ tag.id } already has a primary #{ language } name; " + + "cannot register certified name #{ name.inspect }" + end missing_locales = locales.to_a.uniq(&:language_code).reject { |locale| primary_names.key?(locale.language_code) } @@ -31,9 +50,14 @@ class TagNameCompletion TagVersioning.ensure_snapshot!(tag, created_by_user: nil) missing_locales.each do |locale| - source_language = locale.language_code == 'ja' ? 'ain' : 'ja' - name = TagName.generate_name(locale, tag, primary_names[source_language]) - primary_names[locale.language_code] = create_name!(locale, tag, name) + primary_names[locale.language_code] = + if certified_names.key?(locale.language_code) + create_certified_name!(locale, tag, certified_names.fetch(locale.language_code)) + else + source_language = locale.language_code == 'ja' ? 'ain' : 'ja' + name = TagName.generate_name(locale, tag, primary_names[source_language]) + create_name!(locale, tag, name) + end end tag.association(:tag_names).reset version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil) @@ -50,8 +74,21 @@ class TagNameCompletion private + def create_certified_name! locale, tag, name + persist_name!(locale, tag, name, auto_generated: false) + rescue ActiveRecord::RecordInvalid => error + raise unless error.record.is_a?(TagName) + + raise CertifiedTagNameTranslations::Error, + "Tag##{ tag.id } certified name #{ name.inspect }: " + + error.record.errors.full_messages.join(', ') + rescue ActiveRecord::RecordNotUnique => error + raise CertifiedTagNameTranslations::Error, + "Tag##{ tag.id } certified name #{ name.inspect } conflicts: #{ error.message }" + end + def create_name! locale, tag, name - persist_name!(locale, tag, name) + persist_name!(locale, tag, name, auto_generated: true) rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error fallback = "Tag_##{ tag.id }" invalid_name = @@ -63,13 +100,13 @@ class TagNameCompletion end raise if name == fallback || !(invalid_name) - persist_name!(locale, tag, fallback) + persist_name!(locale, tag, fallback, auto_generated: true) end - def persist_name! locale, tag, name + def persist_name! locale, tag, name, auto_generated: tag_name = TagName.transaction(requires_new: true) do TagName.create!(tag:, name:, language_code: locale.language_code, - script_code: locale.script_code, primary_flg: true, auto_generated: true) + script_code: locale.script_code, primary_flg: true, auto_generated:) end unless tag_name&.persisted? raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'