From 6f0442d63381d0f72b9830166350d5f3c0dbf615 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 20 Sep 2026 00:11:19 +0900 Subject: [PATCH 01/21] =?UTF-8?q?#55=20=E3=83=9E=E3=82=A4=E3=82=B0?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=83=BB=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E3=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260919000000_create_languages.rb | 20 +++++++++++++ .../migrate/20260919010000_create_scripts.rb | 20 +++++++++++++ .../migrate/20260919020000_create_locales.rb | 25 +++++++++++++++++ ...20260919030000_add_columns_to_tag_names.rb | 28 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 backend/db/migrate/20260919000000_create_languages.rb create mode 100644 backend/db/migrate/20260919010000_create_scripts.rb create mode 100644 backend/db/migrate/20260919020000_create_locales.rb create mode 100644 backend/db/migrate/20260919030000_add_columns_to_tag_names.rb diff --git a/backend/db/migrate/20260919000000_create_languages.rb b/backend/db/migrate/20260919000000_create_languages.rb new file mode 100644 index 0000000..e9f1887 --- /dev/null +++ b/backend/db/migrate/20260919000000_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/20260919010000_create_scripts.rb b/backend/db/migrate/20260919010000_create_scripts.rb new file mode 100644 index 0000000..1ebb5ac --- /dev/null +++ b/backend/db/migrate/20260919010000_create_scripts.rb @@ -0,0 +1,20 @@ +class CreateScripts < ActiveRecord::Migration[8.0] + def up + create_table :scripts, id: { type: 'CHAR(4)' }, primary_key: :code do |t| + t.string :name, null: false + t.datetime :deprecated_at, index: true + t.datetime :created_at, null: false + end + + execute <<~SQL + INSERT INTO + scripts(code, name, created_at) + VALUES + ('Jpan', '漢字および仮名文字', #{ connection.quote Time.current }) + SQL + end + + def down + drop_table :scripts + end +end diff --git a/backend/db/migrate/20260919020000_create_locales.rb b/backend/db/migrate/20260919020000_create_locales.rb new file mode 100644 index 0000000..afcd475 --- /dev/null +++ b/backend/db/migrate/20260919020000_create_locales.rb @@ -0,0 +1,25 @@ +class CreateLocales < ActiveRecord::Migration[8.0] + def up + create_table :locales, id: { type: :string, limit: 32 }, primary_key: :code do |t| + t.string :language_code, limit: 16, null: false, index: true + t.column :script_code, 'CHAR(4)', null: false + t.string :name, null: false + t.datetime :deprecated_at, index: true + t.datetime :created_at, null: false + + t.foreign_key :languages, column: :language_code, primary_key: :code + t.foreign_key :scripts, column: :script_code, primary_key: :code + end + + execute <<~SQL + INSERT INTO + locales(code, language_code, script_code, name, created_at) + VALUES + ('ja', 'ja', 'Jpan', '日本語', #{ connection.quote Time.current }) + SQL + end + + def down + drop_table :locales + end +end diff --git a/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb b/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb new file mode 100644 index 0000000..c0e3784 --- /dev/null +++ b/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb @@ -0,0 +1,28 @@ +class AddColumnsToTagNames < ActiveRecord::Migration[8.0] + def up + add_column :tag_names, :language_code, :string, + limit: 16, null: false, after: :id, default: 'ja' + add_column :tag_names, :script_code, 'CHAR(4)', + null: false, after: :name, default: 'Jpan' + + 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 + end + + def down + 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, :script_code + remove_column :tag_names, :language_code + end +end -- 2.53.0 From 886128a7a878563b0976cc8ca549b56a09c145b3 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 20 Sep 2026 21:01:23 +0900 Subject: [PATCH 02/21] #55 --- backend/app/models/language.rb | 3 + backend/app/models/locale.rb | 6 ++ backend/app/models/script.rb | 3 + ...20260919030000_add_columns_to_tag_names.rb | 33 ++++++++++- backend/db/schema.rb | 58 +++++++++++++------ 5 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 backend/app/models/language.rb create mode 100644 backend/app/models/locale.rb create mode 100644 backend/app/models/script.rb diff --git a/backend/app/models/language.rb b/backend/app/models/language.rb new file mode 100644 index 0000000..cd37ec3 --- /dev/null +++ b/backend/app/models/language.rb @@ -0,0 +1,3 @@ +class Language < ApplicationRecord + has_many :languages, class_name: 'Locale', foreign_key: :language_code +end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb new file mode 100644 index 0000000..06b644b --- /dev/null +++ b/backend/app/models/locale.rb @@ -0,0 +1,6 @@ +class Locale < ApplicationRecord + belongs_to :language_code, class_name: 'Language', foreign_key: :code + belongs_to :script_code, class_name: 'Script', foreign_key: :code + + def self.nipponese = Locale.find('ja') +end diff --git a/backend/app/models/script.rb b/backend/app/models/script.rb new file mode 100644 index 0000000..43abe1f --- /dev/null +++ b/backend/app/models/script.rb @@ -0,0 +1,3 @@ +class Script < ApplicationRecord + ; +end diff --git a/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb b/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb index c0e3784..083a414 100644 --- a/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb +++ b/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb @@ -1,9 +1,12 @@ 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: :id, default: 'ja' + limit: 16, null: false, after: :tag_id, default: 'ja' add_column :tag_names, :script_code, 'CHAR(4)', null: false, after: :name, default: 'Jpan' + add_column :tag_names, :primary_flg, :boolean, + null: false, after: :script_code, default: true add_foreign_key :tag_names, :languages, column: :language_code, primary_key: :code add_foreign_key :tag_names, :scripts, column: :script_code, primary_key: :code @@ -13,16 +16,44 @@ class AddColumnsToTagNames < ActiveRecord::Migration[8.0] change_column_default :tag_names, :language_code, from: 'ja', to: nil change_column_default :tag_names, :script_code, from: 'Jpan', to: nil + + execute <<~SQL + UPDATE + tag_names tn + LEFT JOIN + tags AS t + ON + t.tag_name_id = COALESCE(tn.canonical_id, tn.id) + SET + tn.tag_id = t.id + , tn.primary_flg = CASE + WHEN tn.canonical_id IS NULL THEN + 1 + ELSE + 0 + END + SQL + + change_column_default :tag_names, :primary_flg, from: true, to: nil + + add_column :tag_names, :primary_tag_id, :bigint, + as: 'CASE WHEN primary_flg THEN tag_id ELSE NULL END' + add_index :tag_names, [:primary_tag_id, :language_code], unique: true end def down + remove_index :tag_names, [:primary_tag_id, :language_code] + remove_column :tag_names, :primary_tag_id + remove_foreign_key :tag_names, column: :language_code remove_foreign_key :tag_names, column: :script_code remove_index :tag_names, [:language_code, :name] add_index :tag_names, :name, unique: true + remove_column :tag_names, :primary_flg remove_column :tag_names, :script_code remove_column :tag_names, :language_code + remove_reference :tag_names, :tag, foreign_key: true end end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 5ebbcc5..74bc2d4 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_07_27_123600) do +ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) 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 @@ -130,6 +130,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true end + create_table "languages", primary_key: "code", id: { type: :string, limit: 16 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_languages_on_deprecated_at" + end + + create_table "locales", primary_key: "code", id: { type: :string, limit: 32 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "language_code", limit: 16, null: false + t.string "script_code", limit: 4, null: false + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" + t.index ["language_code"], name: "index_locales_on_language_code" + t.index ["script_code"], name: "fk_rails_0b74ce96a8" + end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -384,6 +402,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end + create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" + end + create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -395,19 +420,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do t.index ["user_id"], name: "index_settings_on_user_id", unique: true end - create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.integer "no", null: false - t.string "alt_text" - t.binary "sha256", limit: 32, null: false - t.bigint "created_by_user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" - t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true - t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true - end - create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "parent_tag_id", null: false @@ -429,14 +441,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do end create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id" + t.string "language_code", limit: 16, null: false t.string "name", null: false + t.string "script_code", limit: 4, null: false + t.boolean "primary_flg", null: false t.bigint "canonical_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["discarded_at"], name: "index_tag_names_on_discarded_at" - t.index ["name"], name: "index_tag_names_on_name", unique: true + t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true + t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true + t.index ["script_code"], name: "fk_rails_dd783b3d1c" + t.index ["tag_id"], name: "index_tag_names_on_tag_id" end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -632,7 +652,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" - t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" @@ -701,6 +720,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "users", column: "created_by_id" + add_foreign_key "locales", "languages", column: "language_code", primary_key: "code" + add_foreign_key "locales", "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" @@ -736,7 +757,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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", "tags" @@ -766,8 +790,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) do add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" add_foreign_key "user_theme_slots", "users" - add_foreign_key "wiki_assets", "users", column: "created_by_user_id" - add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" -- 2.53.0 From b0df11c5008d3f354fcf140583b1c1fa4c001b98 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 21 Sep 2026 01:27:10 +0900 Subject: [PATCH 03/21] #55 --- .../app/controllers/materials_controller.rb | 8 +-- backend/app/controllers/posts_controller.rb | 22 +++--- backend/app/models/locale.rb | 22 +++++- backend/app/models/tag.rb | 47 +++++++----- backend/app/models/tag_name.rb | 8 ++- backend/db/schema.rb | 71 ++++--------------- 6 files changed, 87 insertions(+), 91 deletions(-) diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 17cd4d0..3383806 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -236,10 +236,10 @@ class MaterialsController < ApplicationController nil end - def resolve_material_tag! tag_name_raw - tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) - tag = tag_name.tag - tag || Tag.create!(tag_name:, category: :material) + def resolve_material_tag! locale, tag_name_raw + tag_name = TagName.find_undiscard_or_create_by!(language_code: locale.language_code, + name: tag_name_raw) + tag_name.tag || Tag.create!(tag_name:, category: :material) end def material_index_needs_tag_name? filters diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 3f5a92c..d59a725 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -311,6 +311,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] @@ -332,7 +333,8 @@ class PostsController < ApplicationController base_snapshot = post_snapshot_from_version(base_version) current_snapshot = post_snapshot_from_record(post) end - incoming_snapshot = post_incoming_snapshot(title:, + incoming_snapshot = post_incoming_snapshot(locale:, + title:, original_created_from:, original_created_before:, tag_names:, @@ -361,7 +363,7 @@ 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 @@ -733,11 +735,11 @@ class PostsController < ApplicationController end 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) => + Tag.normalise_tags!(locale, tag_names, with_tagme: false, deny_deprecated: true, + with_sections: true) => { tags:, sections: } tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) @@ -873,7 +875,7 @@ class PostsController < ApplicationController (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? end - def apply_post_snapshot! post, snapshot + def apply_post_snapshot! locale, post, snapshot PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) post.update!(title: snapshot[:title], @@ -881,10 +883,10 @@ class PostsController < ApplicationController original_created_from: snapshot[:original_created_from], original_created_before: snapshot[:original_created_before]) - Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, - deny_deprecated: true, - with_sections: true) => - { tags: editable_tags, sections: } + Tag.normalise_tags!(locale, snapshot[:tag_names], + with_tagme: false, + deny_deprecated: true, + with_sections: true) => { tags: editable_tags, sections: } TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) readonly_tags = post.tags.nico.to_a diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index 06b644b..51f4954 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -1,6 +1,24 @@ class Locale < ApplicationRecord - belongs_to :language_code, class_name: 'Language', foreign_key: :code - belongs_to :script_code, class_name: 'Script', foreign_key: :code + after_create_commit :generate_tag_names! + + belongs_to :language, foreign_key: :language_code, primary_key: :code + belongs_to :script, foreign_key: :script_code, primary_key: :code def self.nipponese = Locale.find('ja') + + private + + def generate_tag_names! + tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) + Tag.where.not(id: tag_ids).find_each do + TagName.create!(tag_id: _1.id, + language_code:, + name: TagName.generate_name(self, _1, _1.name), + script_code:, + primary_flg: true, + # TODO: 公証実装したら書く. + # auto_generated: true, + canonical_id: nil) + end + end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 202610b..10d6bb5 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -56,6 +56,7 @@ class Tag < ApplicationRecord has_many :tag_versions has_many :nico_tag_versions + has_many :tag_names belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -105,18 +106,20 @@ 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.tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta) + def self.bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta) + def self.no_deerjikist = + find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta) + def self.video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta) + def self.niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta) + def self.youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta) - def self.normalise_tags! tag_names, with_tagme: true, - with_no_deerjikist: true, - deny_nico: true, - deny_deprecated: false, - with_sections: false + def self.normalise_tags! locale, tag_names, + with_tagme: true, + with_no_deerjikist: true, + deny_nico: true, + deny_deprecated: false, + with_sections: false if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } raise NicoTagNormalisationError end @@ -145,7 +148,7 @@ class Tag < ApplicationRecord name = TagName.canonicalise(name).first - find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| + find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| if deny_deprecated && tag.deprecated? raise DeprecatedTagNormalisationError, [tag.name] end @@ -233,13 +236,23 @@ class Tag < ApplicationRecord [left_end_ms, right_end_ms].max end - def self.find_or_create_by_tag_name! name, category: - tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) - tn = tn.canonical if tn.canonical_id? + def self.find_or_create_by_tag_name! locale, name, category: + language_code = locale.language_code + name = name.to_s.strip - Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| - t.category = category + tn = TagName.find_or_create_by!(language_code:, name:) do + _1.script_code = locale.script_code + _1.primary_flg = true end + + tag = tn.tag + unless tag + tag = Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do + _1.category = category + end + end + + tag rescue ActiveRecord::RecordNotUnique retry end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index de79d10..12fea7a 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,7 +1,7 @@ class TagName < ApplicationRecord include MyDiscard - has_one :tag + belongs_to :tag has_one :wiki_page belongs_to :canonical, class_name: 'TagName', optional: true @@ -23,6 +23,12 @@ class TagName < ApplicationRecord names.map { |name| tns[name]&.canonical&.name || name }.uniq end + def self.generate_name locale, tag, name + # TODO: 言語ごとの自動命名ロジック完成したら書く. + + "Tag_##{ tag.id }" + end + private def canonical_must_be_canonical diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 74bc2d4..5690ffa 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_19_030000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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 @@ -72,7 +72,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id" - t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" end create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -130,24 +129,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) 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.string "name", null: false - t.datetime "deprecated_at" - t.datetime "created_at", null: false - t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" - t.index ["language_code"], name: "index_locales_on_language_code" - t.index ["script_code"], name: "fk_rails_0b74ce96a8" - end - create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -293,10 +274,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" - t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } - t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at" + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at" t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true - t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" end create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -305,14 +285,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" - t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" end create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false t.float "cos", null: false - t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -379,8 +358,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" - t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" - t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema" end @@ -399,14 +376,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.index ["url"], name: "index_posts_on_url", unique: true t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" - t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" - end - - create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name", null: false - t.datetime "deprecated_at" - t.datetime "created_at", null: false - t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -414,6 +383,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "theme", default: "system", null: false + t.string "display_density", default: "comfortable", null: false + t.string "font_size", default: "normal", null: false + t.integer "post_list_limit", default: 50, null: false + t.string "post_list_order", default: "created_at_desc", null: false + t.string "viewed_post_display", default: "show", null: false + t.boolean "tag_autocomplete_nico", default: true, null: false t.string "auto_fetch_title", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "wiki_editor_mode", default: "split", null: false @@ -441,29 +416,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) 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.bigint "canonical_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" - t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["discarded_at"], name: "index_tag_names_on_discarded_at" - t.index ["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.index ["name"], name: "index_tag_names_on_name", unique: true end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false t.float "cos", null: false - t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end @@ -479,10 +446,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" 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 ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at" + t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at" 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" end create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -498,7 +464,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" - t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -657,7 +622,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" - t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -702,8 +666,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) do t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" - t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" - t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" @@ -720,8 +682,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) 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", "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" @@ -757,10 +717,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_19_030000) 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", "tags" -- 2.53.0 From 1b1130ccd900aa9313f10a0146c42548910f058b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 22 Sep 2026 02:56:58 +0900 Subject: [PATCH 04/21] =?UTF-8?q?#55=20=E3=81=84=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E3=82=93=E4=B8=AD=E6=96=AD=EF=BC=9B=E5=A4=96=E9=83=A8=E3=82=BF?= =?UTF-8?q?=E3=82=B0=E5=88=86=E9=9B=A2=E5=84=AA=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controllers/application_controller.rb | 2 + .../app/controllers/materials_controller.rb | 7 +- .../app/controllers/nico_tags_controller.rb | 7 +- backend/app/controllers/posts_controller.rb | 7 +- backend/app/controllers/tags_controller.rb | 13 ++-- backend/app/models/tag.rb | 10 +-- backend/app/models/tag_name.rb | 2 +- backend/app/services/post_creator.rb | 18 ++--- backend/db/schema.rb | 72 +++++++++++++++---- backend/lib/tasks/sync_nico.rake | 2 +- 10 files changed, 98 insertions(+), 42 deletions(-) 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/materials_controller.rb b/backend/app/controllers/materials_controller.rb index be1353a..8bd5dae 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -72,6 +72,7 @@ class MaterialsController < ApplicationController 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,7 +90,7 @@ 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) @@ -237,9 +238,7 @@ class MaterialsController < ApplicationController end def resolve_material_tag! locale, tag_name_raw - tag_name = TagName.find_or_create_by!(language_code: locale.language_code, - name: tag_name_raw) - tag_name.tag || Tag.create!(tag_name:, category: :material) + Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material) end def material_index_needs_tag_name? filters diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index ce63840..80ad31d 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -70,6 +70,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 = Tag.find(id) @@ -79,8 +81,9 @@ 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) if linked_tags.any? { |t| t.nico? } raise Tag::NicoTagNormalisationError end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index e600f1a..3300d2a 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -690,9 +690,10 @@ class PostsController < ApplicationController 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!(locale, tag_names, with_tagme: false, deny_deprecated: true, - with_sections: true) => - { tags:, sections: } + Tag.normalise_tags!(locale, tag_names, + with_tagme: 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:) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index f152d19..4ec260b 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -278,6 +278,8 @@ class TagsController < ApplicationController return unless validate_tag_rename(tag, name) + locale = resolve_locale! + alias_names = params[:aliases].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq deprecated = bool?(:deprecated) @@ -308,7 +310,7 @@ class TagsController < ApplicationController alias_names.delete(name) update_aliases!(tag, alias_names) - update_parent_tags!(tag, parent_names) + update_parent_tags!(locale, tag, parent_names) tag.reload @@ -659,10 +661,11 @@ class TagsController < ApplicationController end end - def update_parent_tags! tag, parent_names - parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, - with_no_deerjikist: false, - deny_nico: true) + def update_parent_tags! locale, tag, parent_names + parent_tags = Tag.normalise_tags!(locale, parent_names, + with_tagme: false, + with_no_deerjikist: false, + deny_nico: true) old_parent_tags = tag.parents.to_a diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 9c80f71..6fdbccd 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -242,12 +242,14 @@ class Tag < ApplicationRecord end tag = tn.tag - unless tag - tag = Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do - _1.category = category - end + if tag + tag.update!(tag_name_id: tn.id) if tag.tag_name_id != tn.id + return tag end + tag = Tag.create!(tag_name: tn, category:) + tn.update!(tag:) + tag rescue ActiveRecord::RecordNotUnique retry diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index a70aa4b..82be505 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,5 +1,5 @@ class TagName < ApplicationRecord - belongs_to :tag + belongs_to :tag, optional: true has_one :wiki_page belongs_to :canonical, class_name: 'TagName', optional: true diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index 788a671..fd834b1 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], @@ -25,7 +25,7 @@ class PostCreator post_tags = planned_post_tags sections = planned_sections TagVersioning.record_tag_snapshots!(snapshot_tags, created_by_user: @actor) - post.video_ms = planned_video_ms + post.video_ms = planned_video_ms(locale) post.save! sync_post_tags!(post, post_tags, sections) sync_parent_posts!(post, planned_parent_post_ids) @@ -53,15 +53,15 @@ class PostCreator def planned_parent_post_ids = planned_create_attributes[:normalised_parent_post_ids] - def planned_video_ms - planned_create_attributes[:video_ms] + def planned_video_ms locale + planned_create_attributes(locale)[:video_ms] end - def planned_create_attributes + 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, @@ -92,13 +92,13 @@ class PostCreator 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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 58c768b..7b68f5f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -72,6 +72,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id" + t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative" end create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -129,6 +130,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" + t.index ["language_code"], name: "index_locales_on_language_code" + t.index ["script_code"], name: "fk_rails_0b74ce96a8" + end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -274,9 +293,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" - t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at" - t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at" + t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } + t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true + t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" end create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -285,13 +305,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" + t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" end create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "target_post_id", null: false t.float "cos", null: false - t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" + t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end @@ -347,6 +368,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema" end @@ -365,6 +388,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.index ["url"], name: "index_posts_on_url", unique: true t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" + t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" + end + + create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" end create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -372,12 +403,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "theme", default: "system", null: false - t.string "display_density", default: "comfortable", null: false - t.string "font_size", default: "normal", null: false - t.integer "post_list_limit", default: 50, null: false - t.string "post_list_order", default: "created_at_desc", null: false - t.string "viewed_post_display", default: "show", null: false - t.boolean "tag_autocomplete_nico", default: true, null: false t.string "auto_fetch_title", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "wiki_editor_mode", default: "split", null: false @@ -405,19 +430,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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.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" end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "target_tag_id", null: false t.float "cos", null: false - t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" + t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" end @@ -433,9 +466,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.datetime "created_at", null: false t.bigint "created_by_user_id" 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" - t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_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" end create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -449,6 +483,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" + t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -615,11 +650,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" + t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -664,6 +701,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" @@ -680,6 +719,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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", "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" @@ -711,7 +752,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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" @@ -740,6 +784,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" add_foreign_key "user_theme_slots", "users" + add_foreign_key "wiki_assets", "users", column: "created_by_user_id" + add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index f95fba1..dac9e25 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -124,7 +124,7 @@ namespace :nico do datum['tags'].each do |raw| name = TagNameSanitisationRule.sanitise("nico:#{ raw }") - tag = Tag.find_or_create_by_tag_name!(name, category: :nico) + tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, name, category: :nico) event_type = tag.nico_tag_versions.exists? ? :update : :create NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil) -- 2.53.0 From 115a14205b2dd1a273d42f8cfa236ae8de6447a6 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 22 Sep 2026 03:04:21 +0900 Subject: [PATCH 05/21] #55 --- ....rb => 20260922000000_create_languages.rb} | 0 ...ts.rb => 20260922010000_create_scripts.rb} | 0 ...es.rb => 20260922020000_create_locales.rb} | 0 ...0260922030000_add_columns_to_tag_names.rb} | 0 backend/db/schema.rb | 40 +------------------ 5 files changed, 1 insertion(+), 39 deletions(-) rename backend/db/migrate/{20260919000000_create_languages.rb => 20260922000000_create_languages.rb} (100%) rename backend/db/migrate/{20260919010000_create_scripts.rb => 20260922010000_create_scripts.rb} (100%) rename backend/db/migrate/{20260919020000_create_locales.rb => 20260922020000_create_locales.rb} (100%) rename backend/db/migrate/{20260919030000_add_columns_to_tag_names.rb => 20260922030000_add_columns_to_tag_names.rb} (100%) diff --git a/backend/db/migrate/20260919000000_create_languages.rb b/backend/db/migrate/20260922000000_create_languages.rb similarity index 100% rename from backend/db/migrate/20260919000000_create_languages.rb rename to backend/db/migrate/20260922000000_create_languages.rb diff --git a/backend/db/migrate/20260919010000_create_scripts.rb b/backend/db/migrate/20260922010000_create_scripts.rb similarity index 100% rename from backend/db/migrate/20260919010000_create_scripts.rb rename to backend/db/migrate/20260922010000_create_scripts.rb diff --git a/backend/db/migrate/20260919020000_create_locales.rb b/backend/db/migrate/20260922020000_create_locales.rb similarity index 100% rename from backend/db/migrate/20260919020000_create_locales.rb rename to backend/db/migrate/20260922020000_create_locales.rb diff --git a/backend/db/migrate/20260919030000_add_columns_to_tag_names.rb b/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb similarity index 100% rename from backend/db/migrate/20260919030000_add_columns_to_tag_names.rb rename to backend/db/migrate/20260922030000_add_columns_to_tag_names.rb diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 7b68f5f..1cb6423 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -130,24 +130,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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.string "name", null: false - t.datetime "deprecated_at" - t.datetime "created_at", null: false - t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" - t.index ["language_code"], name: "index_locales_on_language_code" - t.index ["script_code"], name: "fk_rails_0b74ce96a8" - end - create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -391,13 +373,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end - create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name", null: false - t.datetime "deprecated_at" - t.datetime "created_at", null: false - t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" - end - create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -430,20 +405,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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.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 ["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.index ["name"], name: "index_tag_names_on_name", unique: true end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -719,8 +686,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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", "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" @@ -752,10 +717,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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" -- 2.53.0 From 5abb6cca98a608299857910559ff0e000dd52145 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 26 Sep 2026 20:23:11 +0900 Subject: [PATCH 06/21] #55 --- backend/app/controllers/posts_controller.rb | 4 +- backend/app/services/post_bulk_creator.rb | 8 +- backend/app/services/post_creator.rb | 2 +- backend/db/schema.rb | 42 ++- backend/spec/factories/tag_names.rb | 18 +- backend/spec/factories/tags.rb | 5 + backend/spec/models/locale_spec.rb | 69 +++++ .../spec/models/material_export_item_spec.rb | 2 +- backend/spec/models/post_version_spec.rb | 4 +- .../models/tag_name_sanitisation_rule_spec.rb | 52 +++- backend/spec/models/tag_name_spec.rb | 73 +++++ backend/spec/models/tag_spec.rb | 200 +++++++++---- .../spec/requests/gekanator_learning_spec.rb | 11 +- backend/spec/requests/gekanator_posts_spec.rb | 11 +- .../spec/requests/locale_write_paths_spec.rb | 232 +++++++++++++++ backend/spec/requests/materials_spec.rb | 34 +-- backend/spec/requests/nico_tags_spec.rb | 8 +- backend/spec/requests/posts_spec.rb | 111 ++++--- backend/spec/requests/tag_versions_spec.rb | 4 +- .../tag_wiki_history_integrity_spec.rb | 4 +- backend/spec/requests/tags_spec.rb | 279 ++++++++---------- backend/spec/requests/wiki_conflict_spec.rb | 2 +- .../requests/wiki_history_integrity_spec.rb | 2 +- backend/spec/requests/wiki_spec.rb | 29 +- .../requests/wiki_title_collision_spec.rb | 2 +- .../services/material_sync_importer_spec.rb | 2 +- .../nico_tag_version_recorder_spec.rb | 2 +- .../spec/services/post_bulk_creator_spec.rb | 11 +- .../spec/services/post_create_plan_spec.rb | 64 +++- backend/spec/services/post_creator_spec.rb | 34 ++- .../services/post_import_previewer_spec.rb | 2 +- .../services/wiki/commit_integrity_spec.rb | 10 +- backend/spec/services/wiki/commit_spec.rb | 10 +- .../services/wiki_version_recorder_spec.rb | 2 +- backend/spec/services/youtube/sync_spec.rb | 6 +- backend/spec/support/locale_context.rb | 13 + backend/spec/tasks/nico_sync_spec.rb | 2 +- .../spec/tasks/post_similarity_calc_spec.rb | 8 +- .../spec/tasks/tag_similarity_calc_spec.rb | 8 +- 39 files changed, 1005 insertions(+), 377 deletions(-) create mode 100644 backend/spec/models/locale_spec.rb create mode 100644 backend/spec/models/tag_name_spec.rb create mode 100644 backend/spec/requests/locale_write_paths_spec.rb create mode 100644 backend/spec/support/locale_context.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 73710f4..228cb7b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -213,6 +213,8 @@ class PostsController < ApplicationController 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], @@ -242,7 +244,7 @@ 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 diff --git a/backend/app/services/post_bulk_creator.rb b/backend/app/services/post_bulk_creator.rb index 1e75e24..e525c57 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,7 +61,7 @@ class PostBulkCreator private - def create_row actor, attributes, index + def create_row locale, actor, attributes, index preflight = PostCreatePreflight.new( attributes: attributes, @@ -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_creator.rb b/backend/app/services/post_creator.rb index fd834b1..dbe4c89 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!(locale) + def create! locale thumbnail_attachment = prepare_thumbnail_attachment post = Post.new(title: @attributes[:title].presence, url: @attributes[:url], diff --git a/backend/db/schema.rb b/backend/db/schema.rb index c4ce56a..c6b886b 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_030000) 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,24 @@ 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.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" + t.index ["language_code"], name: "index_locales_on_language_code" + t.index ["script_code"], name: "fk_rails_0b74ce96a8" + end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -388,6 +406,13 @@ 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 "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" + end + create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -420,12 +445,20 @@ 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.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" end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -701,6 +734,8 @@ 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", "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 +769,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/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index a33be1e..4af5bcf 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -1,5 +1,21 @@ FactoryBot.define do factory :tag_name do - name { "tag-#{SecureRandom.hex(4)}" } + language_code { 'ja' } + script_code { 'Jpan' } + primary_flg { true } + sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" } + + trait :alias do + primary_flg { false } + + after(:create) do |tag_name| + owner = tag_name.canonical&.tag + raise ArgumentError, 'alias requires a canonical tag' unless owner + + # The alias validation still rejects tag_id; its contract is tested separately. + tag_name.update_columns(tag_id: owner.id) + tag_name.association(:tag).reset + end + end end end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 55db316..a9df735 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -6,10 +6,15 @@ FactoryBot.define do category { :general } post_count { 0 } + association :tag_name after(:build) do |tag, evaluator| tag.name = evaluator.name if evaluator.name.present? end + + after(:create) do |tag| + tag.tag_name.update!(tag:) if tag.tag_name.tag_id != tag.id + 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..d87bb56 --- /dev/null +++ b/backend/spec/models/locale_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe Locale, type: :model do + def prepare_french_reference! + Language.find_or_create_by!(code: 'fr') { _1.name = 'French' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + end + + def create_french_locale! + prepare_french_reference! + described_class.create!(code: 'fr', language_code: 'fr', + script_code: 'Latn', name: 'French') + end + + it 'generates a primary name for every existing tag using the name generator' do + first = create(:tag, name: 'first_existing_tag') + second = create(:tag, 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, first.name) + .and_return('name_from_generator') + + locale = create_french_locale! + + expect(TagName).to have_received(:generate_name).with(locale, first, first.name) + expect(TagName).to have_received(:generate_name).with(locale, second, second.name) + expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) + .to have_attributes(name: 'name_from_generator', tag_id: first.id, + language_code: 'fr', script_code: 'Latn', + primary_flg: true, canonical_id: nil) + expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) + .to have_attributes(tag_id: second.id, language_code: 'fr', + script_code: 'Latn', primary_flg: true, canonical_id: nil) + expect(TagName.where(language_code: 'fr', primary_flg: true, + tag_id: [first.id, second.id]).count).to eq(2) + end + + it 'does not duplicate an existing primary name in the new language' do + prepare_french_reference! + tag = create(:tag, name: 'already_named') + existing = create(:tag_name, name: 'nom_existant', tag:, + language_code: 'fr', script_code: 'Latn') + + create_french_locale! + + expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true)) + .to contain_exactly(existing) + expect(existing.reload).to have_attributes( + tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: true) + end + + it 'creates a primary name when the language has only an alias' do + prepare_french_reference! + tag = create(:tag, name: 'alias_only_tag') + alias_name = create(:tag_name, :alias, name: 'alias_fr', + canonical: tag.tag_name, + language_code: 'fr', script_code: 'Latn') + + create_french_locale! + + expect(alias_name.reload).to have_attributes( + canonical_id: tag.tag_name_id, tag_id: tag.id, + language_code: 'fr', script_code: 'Latn', primary_flg: false) + generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) + expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', + script_code: 'Latn', primary_flg: true) + expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true).count).to eq(1) + end +end diff --git a/backend/spec/models/material_export_item_spec.rb b/backend/spec/models/material_export_item_spec.rb index d98e225..07e08f3 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, 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_version_spec.rb b/backend/spec/models/post_version_spec.rb index 4cc0e82..ccbb20f 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, 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/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index b82f9fa..6fb9929 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -34,9 +34,41 @@ RSpec.describe TagNameSanitisationRule, type: :model do described_class.create!(priority: 10, source_pattern: '_', replacement: '') end + context 'when only another language has the sanitised name' do + include_context 'English locale' + + it 'keeps both names when sanitisation produces a cross-language match' do + japanese = create(:tag_name, name: 'foobar') + english = create(:tag_name, name: 'temporary', + language_code: 'en', script_code: 'Latn') + english.update_columns(name: 'foo_bar') + + expect { described_class.apply! }.not_to change(TagName, :count) + + expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en') + expect(japanese.reload).to have_attributes(name: 'foobar', language_code: 'ja') + end + + it 'renames without merging tags or deleting either language identity' do + japanese = create(:tag, name: 'foobar') + english_name = create(:tag_name, name: 'temporary', + language_code: 'en', script_code: 'Latn') + english = create(:tag, 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(japanese.reload.tag_name).to have_attributes( + name: 'foobar', language_code: 'ja', tag_id: japanese.id) + expect(english.reload.tag_name_id).to eq(english_name.id) + 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 @@ -48,9 +80,9 @@ RSpec.describe TagNameSanitisationRule, type: :model do end context 'when a conflicting canonical tag_name exists' do - let!(:existing) { TagName.create!(name: 'foobar') } + 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,7 +95,7 @@ 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!(:existing) { create(:tag_name, name: 'foobar') } let!(:source_tag) { create(:tag, name: 'tmp', category: :general) } let!(:source_tag_name_id) { source_tag.tag_name_id } @@ -82,10 +114,10 @@ RSpec.describe TagNameSanitisationRule, type: :model do context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } let!(:alias_name) do - TagName.create!(name: 'foobar', canonical: existing_tag.tag_name) + create(:tag_name, :alias, name: 'foobar', canonical: existing_tag.tag_name) 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 @@ -100,11 +132,11 @@ RSpec.describe TagNameSanitisationRule, type: :model do 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, 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, tag_name: source_tn, category: :general) } let!(:source_tag_name_id) { source_tn.id } before do diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb new file mode 100644 index 0000000..66aa120 --- /dev/null +++ b/backend/spec/models/tag_name_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe TagName, type: :model do + include_context 'English locale' + + it 'allows the same name in different languages' do + create(:tag_name, name: 'shared_name') + english = build(:tag_name, name: 'shared_name', + language_code: 'en', script_code: 'Latn') + + expect(english).to be_valid + expect { english.save! }.to change(described_class, :count).by(1) + end + + it 'rejects a duplicate name within the same language' do + create(:tag_name, name: 'shared_name') + duplicate = build(:tag_name, name: 'shared_name') + + expect(duplicate).to be_invalid + expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) + end + + it 'creates Japanese factory names without requiring a tag' do + name = create(:tag_name) + + expect(name.reload).to have_attributes( + language_code: 'ja', script_code: 'Jpan', primary_flg: true, tag_id: nil) + end + + it 'persists both sides of the representative tag factory association' do + tag = create(:tag) + + expect(tag.reload.tag_name.reload.tag_id).to eq(tag.id) + expect(tag.tag_name.tag).to eq(tag) + end + + it 'accepts an alias owned by the canonical tag' do + tag = create(:tag) + alias_name = build(:tag_name, name: 'valid_alias', canonical: tag.tag_name, + tag:, primary_flg: false) + + expect(alias_name).to have_attributes( + canonical_id: tag.tag_name_id, tag_id: tag.id, + primary_flg: false, language_code: 'ja') + expect(alias_name).to be_valid + end + + describe '.canonicalise' do + it 'resolves only aliases in the requested language' do + japanese = create(:tag, name: 'japanese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name) + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + english_alias = create(:tag_name, :alias, name: 'temporary_alias', + canonical: english_name, + language_code: 'en', script_code: 'Latn') + english_alias.update_columns(name: 'shared_alias') + + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + canonical_id: english_name.id, tag_id: english.id, + primary_flg: false, language_code: 'en') + expect(described_class.canonicalise(locale, ['shared_alias'])) + .to eq(['english_canonical']) + expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) + .to eq(['japanese_canonical']) + end + end +end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index d63be45..e9683dd 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Tag, type: :model do 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,19 +25,49 @@ RSpec.describe Tag, type: :model do end describe '.normalise_tags!' do + it 'canonicalises aliases in the supplied language only' do + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + Locale.insert_all!([ + { code: 'en', language_code: 'en', script_code: 'Latn', + name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en') + english_locale = Locale.find('en') + japanese = create(:tag, name: 'japanese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name) + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + english_alias = create(:tag_name, :alias, name: 'temporary_alias', + canonical: english_name, + language_code: 'en', script_code: 'Latn') + english_alias.update_columns(name: 'shared_alias') + + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + canonical_id: english_name.id, tag_id: english.id, + primary_flg: false, language_code: 'en') + expect(described_class.normalise_tags!( + english_locale, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([english]) + expect(described_class.normalise_tags!( + Locale.nipponese, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([japanese]) + end + it 'rejects deprecated tags when deny_deprecated is enabled' do - 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, + 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, + [deprecated_tag.name], + deny_deprecated: true) }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error| expect(error.tag_names).to eq([deprecated_tag.name]) } @@ -45,88 +75,79 @@ RSpec.describe Tag, type: :model do 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } 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 == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) @@ -183,11 +204,65 @@ 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 + tag = described_class.find_or_create_by_tag_name!( + locale, 'english_name', category: :character) + + expect(tag.tag_name.reload).to have_attributes( + language_code: 'en', script_code: 'Latn', + primary_flg: true, tag_id: tag.id) + expect(tag.category).to eq('character') + end + + it 'creates separate tag identities for the same name in different languages' do + japanese = described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'same_name', category: :general) + english = described_class.find_or_create_by_tag_name!( + locale, 'same_name', category: :character) + + expect(english).not_to eq(japanese) + expect(english.tag_name.language_code).to eq('en') + expect(japanese.tag_name.language_code).to eq('ja') + expect(described_class.find_or_create_by_tag_name!( + locale, 'same_name', category: :general)).to eq(english) + expect(described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'same_name', category: :general)).to eq(japanese) + end + + it 'preserves the V1 representative when looking up another primary language' do + tag = create(:tag, name: '日本語代表名') + representative_id = tag.tag_name_id + english = create(:tag_name, name: 'english_primary', tag:, + language_code: 'en', script_code: 'Latn') + + found = described_class.find_or_create_by_tag_name!( + locale, english.name, category: :general) + + expect(found).to eq(tag) + expect(tag.reload.tag_name_id).to eq(representative_id) + expect(english.reload.tag_id).to eq(tag.id) + end + + it 'normalises names using the supplied locale' do + tags = described_class.normalise_tags!( + locale, ['character:normalised_english'], + with_tagme: false, with_no_deerjikist: false) + + expect(tags.length).to eq(1) + expect(tags.first.tag_name).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) @@ -197,27 +272,38 @@ RSpec.describe Tag, type: :model do it 'reuses the canonical 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', canonical: tag.tag_name) + expect(alias_name.reload).to have_attributes( + canonical_id: tag.tag_name_id, 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 canonical tag through another alias' do + tag = create(:tag) + canonical = tag.tag_name + alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical:) + other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', canonical:) + + expect([alias_name.reload, other_alias.reload]).to all(have_attributes( + canonical_id: canonical.id, 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) end @@ -277,7 +363,7 @@ RSpec.describe Tag, type: :model do 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_alias = create(:tag_name, :alias, name: 'merge_alias', canonical: source_tag_name) TagVersioning.ensure_snapshot!(source_tag, created_by_user: user) original_version = source_tag.tag_versions.first diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 26f271b..855bb75 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -898,12 +898,11 @@ 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, name: 'active_question_tag', category: :general) + deprecated_tag = create(:tag, + name: 'deprecated_question_tag', + category: :general, + deprecated_at: Time.current) [active_tag, deprecated_tag].each do |question_tag| GekanatorQuestion.create!( diff --git a/backend/spec/requests/gekanator_posts_spec.rb b/backend/spec/requests/gekanator_posts_spec.rb index b21e414..7fe627a 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, name: 'active tag', category: :general) + deprecated_tag = create(:tag, + 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..f90a8c3 --- /dev/null +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -0,0 +1,232 @@ +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 + expect(tag_name.tag.tag_name_id).to eq(tag_name.id) + tag_name.tag + end + + ['en', nil].each do |requested_locale| + context "with locale #{ requested_locale.inspect }" do + let(:locale_params) { requested_locale ? { locale: requested_locale } : { } } + let(:expected_locale) { requested_locale ? locale : Locale.nipponese } + + it 'creates post tags in the requested language or Japanese fallback' do + post '/posts', params: locale_params.merge( + title: 'Locale post', url: 'https://example.com/locale-post', + tags: 'locale_post_tag', parent_post_ids: '') + + expect(response).to have_http_status(:created), response.body + tag = expect_localised_tag('locale_post_tag', expected_locale) + expect(Post.find(json.fetch('id')).tags).to include(tag) + end + + it 'updates post tags in the requested language or Japanese fallback' do + record = create(:post) + PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) + + put "/posts/#{ record.id }", params: locale_params.merge( + title: record.title, tags: 'locale_updated_tag', parent_post_ids: '', + base_version_no: record.reload.version_no) + + expect(response).to have_http_status(:ok), response.body + tag = expect_localised_tag('locale_updated_tag', expected_locale) + expect(record.reload.tags).to include(tag) + end + + it 'passes the resolved locale to bulk creation' do + # Worker propagation is exercised in post_bulk_creator_spec. + expect_any_instance_of(PostBulkCreator).to receive(:run) + .with(expected_locale).and_return(results: []) + manifest = [{ title: 'Bulk locale', url: 'https://example.com/bulk-locale', + tags: 'bulk_locale_tag', parent_post_ids: '' }] + + post '/posts/bulk', params: locale_params.merge(posts: JSON.generate(manifest)), + headers: { 'CONTENT_TYPE' => 'multipart/form-data' } + + expect(response).to have_http_status(:ok), response.body + end + + it 'creates materials with tags in the resolved locale' do + post '/materials', params: locale_params.merge( + tag: 'locale_material', url: 'https://example.com/material') + + expect(response).to have_http_status(:created), response.body + tag = expect_localised_tag('locale_material', expected_locale) + expect(Material.find(json.fetch('id')).tag).to eq(tag) + end + + it 'updates materials with tags in the resolved locale' do + tag = create(:tag, category: :material) + material = Material.create!(tag:, url: 'https://example.com/material') + + put "/materials/#{ material.id }", params: locale_params.merge( + tag: 'locale_material_updated', url: material.url) + + expect(response).to have_http_status(:ok), response.body + resolved_tag = expect_localised_tag('locale_material_updated', expected_locale) + expect(material.reload.tag).to eq(resolved_tag) + end + + it 'links Nico external tags to internal tags in the resolved locale' do + external = create(:external_tag) + NicoTagVersionRecorder.record!( + external_tag: external, event_type: :create, created_by_user: member) + + put "/tags/nico/#{ external.id }", params: locale_params.merge(tags: 'locale_link') + + expect(response).to have_http_status(:ok), response.body + tag = expect_localised_tag('locale_link', expected_locale) + expect(external.reload.linked_tags).to contain_exactly(tag) + end + + it 'creates parent tags in the resolved locale' do + tag = create(:tag) + + put "/tags/#{ tag.id }", params: locale_params.merge( + name: tag.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, tag_name: english_name, category: :material) } + let!(:japanese_tag) do + create(:tag, name: 'temporary_japanese', category: :general).tap do |tag| + # Isolate lookup from the separately tested uniqueness validation. + tag.tag_name.update_columns(name: 'shared_name') + end + end + + it 'plans and creates the post using the English identity and category' do + post '/posts', params: { + locale: 'en', title: 'English identity', + url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' } + + expect(response).to have_http_status(:created), response.body + record = Post.find(json.fetch('id')) + expect(record.tags).to include(english_tag) + expect(record.tags).not_to include(japanese_tag) + expect(english_tag.reload.category).to eq('material') + expect(japanese_tag.reload.category).to eq('general') + end + + it 'updates the post using the English identity' do + record = create(:post) + PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) + + put "/posts/#{ record.id }", params: { + locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '', + base_version_no: record.reload.version_no } + + expect(response).to have_http_status(:ok), response.body + expect(record.reload.tags).to include(english_tag) + expect(record.tags).not_to include(japanese_tag) + end + + it 'resolves the English material tag' do + post '/materials', params: { + locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' } + + expect(response).to have_http_status(:created), response.body + expect(Material.find(json.fetch('id')).tag).to eq(english_tag) + expect(Material.where(tag: japanese_tag)).to be_empty + end + + it 'resolves the English parent tag' do + child = create(:tag) + + put "/tags/#{ child.id }", params: { + locale: 'en', name: child.name, category: child.category, deprecated: false, + aliases: '', parent_tags: 'shared_name' } + + expect(response).to have_http_status(:ok), response.body + expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id]) + end + + it 'resolves the English internal tag for a Nico link' do + external = create(:external_tag) + NicoTagVersionRecorder.record!( + external_tag: external, event_type: :create, created_by_user: member) + + put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' } + + expect(response).to have_http_status(:ok), response.body + expect(external.reload.linked_tags).to contain_exactly(english_tag) + end + end + + context 'when renaming or updating aliases in English' do + let!(:english_name) do + create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn') + end + let!(:tag) { create(:tag, tag_name: english_name) } + + it 'allows a rename to a name already used in Japanese' do + japanese = create(:tag, name: 'rename_target') + + patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' } + + expect(response).to have_http_status(:ok), response.body + expect(tag.reload.tag_name).to have_attributes( + name: 'rename_target', language_code: 'en', tag_id: tag.id) + expect(japanese.reload.tag_name).to have_attributes( + name: 'rename_target', language_code: 'ja', tag_id: japanese.id) + end + + it 'creates aliases in the requested language' do + put "/tags/#{ tag.id }", params: { + locale: 'en', name: tag.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', canonical_id: english_name.id, + tag_id: tag.id, primary_flg: false, language_code: 'en') + expect(tag.reload.tag_name_id).to eq(english_name.id) + end + + it 'does not take an alias from another language' do + japanese = create(:tag, name: 'japanese_owner') + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + + put "/tags/#{ tag.id }", params: { + locale: 'en', name: tag.name, category: tag.category, deprecated: false, + aliases: 'shared_alias', parent_tags: '' } + + expect(response).to have_http_status(:ok), response.body + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(TagName.find_by!(language_code: 'en', name: 'shared_alias')) + .to have_attributes( + canonical_id: english_name.id, tag_id: tag.id, + primary_flg: false, language_code: 'en') + end + end +end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index c1ffdf4..17ff0a9 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, 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, 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, name: 'material_scope_root', category: :material) child_b = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'), + create(:tag, name: 'material_scope_b', category: :material) child_a = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'), + create(:tag, name: 'material_scope_a', category: :material) deprecated = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'), + create(:tag, name: 'material_scope_old', category: :material, deprecated_at: Time.current) grandchild = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'), + create(:tag, 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, name: 'material_show', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) @@ -323,8 +323,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, tag_name: general_tag_name, category: :general) post '/materials', params: { tag: 'material_create_general_tag', @@ -384,7 +384,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, name: 'material_update_old', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) @@ -508,7 +508,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, name: 'material_update_existing_no_history', category: :material) expect(existing_tag.tag_versions).to be_empty @@ -531,7 +531,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, name: 'material_update_character_no_history', category: :character) expect(existing_tag.tag_versions).to be_empty @@ -651,8 +651,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, name: 'zip_a', category: :material) } + let!(:tag_b) { create(:tag, 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 +701,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, name: 'material_history', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) @@ -776,7 +776,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, 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..f800afb 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -162,11 +162,11 @@ 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, 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, tag_name: linked_b_name, category: :general) NicoTagVersionRecorder.record!(external_tag: nico_tag, event_type: :create, created_by_user: admin) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 8660634..585dacb 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, 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, tag_name:, category: :general) } + let!(:tag_name2) { create(:tag_name, name: 'unko') } + let!(:tag2) { create(:tag, tag_name: tag_name2, category: :deerjikist) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', canonical: tag_name) } let!(:hit_post) do Post.create!(uploaded_user: user, title: 'hello spec world', @@ -341,17 +341,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, 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, 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, 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', canonical: foo_tag_name) end let!(:foo_only_post) do @@ -772,11 +772,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, + name: 'deprecated_post_tag', + category: :general, + deprecated_at: Time.current) PostTag.create!(post: post_record, tag: deprecated_tag) request @@ -853,9 +852,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, tag_name:, category: :general) + create(:tag_name, :alias, name: "show_query_alias_#{ i }", canonical: tag_name) PostTag.create!(post: post_record, tag:) tag end @@ -1000,7 +999,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', canonical: tag_name) } it '401 when not logged in' do sign_out @@ -1157,11 +1156,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, + name: 'deprecated_direct_tag', + category: :general, + deprecated_at: Time.current) sign_in_as(member) post '/posts', params: post_write_params( @@ -1178,18 +1176,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, name: 'active_child', category: :general) + deprecated_parent = create(:tag, + name: 'deprecated_parent', + category: :general, + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + name: 'deprecated_grandparent', + category: :general, + deprecated_at: Time.current) + active_grandparent = create(:tag, 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) @@ -1622,6 +1618,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 +1655,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, tag_name: tn2, category: :general) put "/posts/#{post_record.id}", params: post_update_params( post_record, @@ -1707,11 +1704,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, + name: 'deprecated_update_tag', + category: :general, + deprecated_at: Time.current) sign_in_as(member) put "/posts/#{ post_record.id }", params: post_update_params( @@ -1986,7 +1982,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!( @@ -2015,7 +2012,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!( @@ -2102,7 +2100,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) @@ -2191,8 +2190,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, tag_name: tag_name2, category: :general) } def snapshot_tags(post) post.snapshot_tag_names.join(' ') @@ -2550,8 +2549,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, tag_name: tag_name2, category: :general) expect do put "/posts/#{post_record.id}", params: post_write_params( @@ -2715,8 +2714,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, 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..f9802a4 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -232,8 +232,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, 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..33401e9 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -8,8 +8,8 @@ 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, tag_name:, category:) end def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 3c1088c..26c1b52 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, tag_name: tn, category: :general) } + let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', canonical: tn) } 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, tag_name: tn2, category: :general) } def response_tags json.fetch('tags') @@ -91,7 +91,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, name: 'meme_only', category: :meme) get '/tags', params: { category: 'meme' } @@ -101,12 +101,11 @@ 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, + name: 'deprecated_filter', + category: :general, + deprecated_at: 1.day.from_now) + active_tag = create(:tag, name: 'active_filter', category: :general) get '/tags', params: { name: '_filter', deprecated: '1' } @@ -122,9 +121,9 @@ RSpec.describe 'Tags API', type: :request do 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, name: 'pc_low', category: :general) + mid = create(:tag, name: 'pc_mid', category: :general) + high = create(:tag, name: 'pc_high', category: :general) low.update_columns(post_count: 1) mid.update_columns(post_count: 3) @@ -142,8 +141,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, name: 'created_old', category: :general) + new_tag = create(:tag, 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 +161,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, name: 'updated_old', category: :general) + new_tag = create(:tag, 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 +181,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, name: 'cat_deerjikist', category: :deerjikist) + create(:tag, name: 'cat_meme', category: :meme) + create(:tag, name: 'cat_character', category: :character) + create(:tag, name: 'cat_general', category: :general) + create(:tag, name: 'cat_material', category: :material) + create(:tag, name: 'cat_meta', category: :meta) create(:external_tag, name: 'cat_nico') get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } @@ -202,20 +201,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) + 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) + 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) + tag_name: create(:tag_name, name: 'z_mixed_page'), + category: :general) end let!(:first_external) do @@ -265,7 +264,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, tag_name: create(:tag_name, name:), category: :general) end get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 } @@ -276,8 +275,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, name: 'fallback_low', category: :general) + high = create(:tag, name: 'fallback_high', category: :general) low.update_columns(post_count: 1) high.update_columns(post_count: 9) @@ -290,7 +289,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, tag_name: create(:tag_name, name:), category: :general) end get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 } @@ -302,10 +301,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, + tag_name: create(:tag_name, name: 'index_parent_tag'), + category: :meme) TagImplication.create!(tag:, parent_tag:) get '/tags', params: { name: 'spec_tag' } @@ -349,10 +347,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, + tag_name: create(:tag_name, name: 'show_parent_tag'), + category: :character) TagImplication.create!(tag:, parent_tag:) request @@ -416,7 +413,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, name: 'mixed_internal', post_count: 2) external = create(:external_tag, id: internal.id, name: 'mixed_external', post_count: 3) @@ -455,7 +452,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, name:, post_count: 1) create(:external_tag, name:, post_count: 1) end @@ -467,9 +464,9 @@ 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, name: 'switch_internal', post_count: 1) + alias_target = create(:tag, category: :general, name: 'alias_target', post_count: 1) + create(:tag_name, :alias, name: 'switch_alias', canonical: alias_target.tag_name) create(:external_tag, name: 'switch_external', post_count: 1) get '/tags/autocomplete', params: { q: 'switch', nico: '0' } @@ -484,11 +481,13 @@ RSpec.describe 'Tags API', type: :request do ['%', '_'].each do |wildcard| it "treats #{ wildcard } literally for canonical, 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, name: literal, post_count: 1) + alias_target = create( + :tag, category: :general, name: 'literal_alias_target', post_count: 1) + create(:tag_name, :alias, name: "#{ literal }_alias", + canonical: alias_target.tag_name) create(:external_tag, name: literal, post_count: 1) - Tag.create!(category: :general, name: 'literalXmatch', post_count: 2) + create(:tag, category: :general, name: 'literalXmatch', post_count: 2) create(:external_tag, name: 'literalXmatch', post_count: 2) get '/tags/autocomplete', params: { q: "literal#{ wildcard }" } @@ -523,11 +522,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, + name: 'spec_deprecated', + category: :general, + deprecated_at: Time.current) deprecated_tag.update_columns(post_count: 1) get '/tags/autocomplete', params: { q: 'spec_', present: '0' } @@ -820,27 +818,27 @@ RSpec.describe 'Tags API', type: :request do 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, 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, 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, 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, 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, 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, name: 'depth_child_general', category: :general) end before do @@ -883,15 +881,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, + name: 'depth_deprecated_middle', + category: :character, + deprecated_at: Time.current) + visible_descendant = create(:tag, + 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 +899,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, + name: 'depth_active_child_below_deprecated', + category: :character) + deprecated_parent = create(:tag, + name: 'depth_deprecated_parent', + category: :character, + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + name: 'depth_deprecated_grandparent', + category: :material, + deprecated_at: Time.current) + active_ancestor = create(:tag, + 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 +937,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, + name: 'depth_root_below_deprecated', + category: :character) + deprecated_parent = create(:tag, + 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 +957,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, + name: 'depth_cycle_first', + category: :character, + deprecated_at: Time.current) + second = create(:tag, + 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 +990,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, 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, 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, name: 'materials_child_b', category: :character) end let!(:grandchild_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material) + create(:tag, name: 'materials_grandchild', category: :material) end let!(:root_material) do @@ -1158,14 +1146,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, + tag_name: create(:tag_name, name: 'put_old_parent'), + category: :general) + kept_parent = create(:tag, + 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) @@ -1310,14 +1296,12 @@ RSpec.describe 'Tags API', type: :request do 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, + tag_name: create(:tag_name, name: 'put_snapshot_old_parent'), + category: :general) + new_parent = create(:tag, + tag_name: create(:tag_name, name: 'put_snapshot_new_parent'), + category: :general) TagImplication.create!(tag:, parent_tag: old_parent) put "/tags/#{ tag.id }", params: { @@ -1454,14 +1438,12 @@ 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, + tag_name: create(:tag_name, name: 'put_alias_old_owner'), + category: :general) + stolen_alias = create(:tag_name, :alias, + name: 'put_stolen_alias', + canonical: old_owner.tag_name) expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') @@ -1492,14 +1474,12 @@ RSpec.describe 'Tags API', type: :request do 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, + tag_name: create(:tag_name, name: 'put_alias_collision_owner'), + category: :general) + stolen_alias = create(:tag_name, :alias, + name: 'put_alias_collision_name', + canonical: old_owner.tag_name) wiki_page = Wiki::Commit.create_content!( tag_name: tag.tag_name, @@ -1515,7 +1495,7 @@ 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 @@ -1534,10 +1514,9 @@ RSpec.describe 'Tags API', type: :request do end it 'parent_tags に指定すると循環する tag は 422 にする' do - child = Tag.create!( - tag_name: TagName.create!(name: 'put_cycle_child'), - category: :general - ) + child = create(:tag, + tag_name: create(:tag_name, name: 'put_cycle_child'), + category: :general) TagImplication.create!(tag: child, parent_tag: tag) 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..7bf7c49 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, + 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, 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, tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( 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..7d34dc6 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, 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..b7811c4 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, 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..9cd5d7b 100644 --- a/backend/spec/services/post_bulk_creator_spec.rb +++ b/backend/spec/services/post_bulk_creator_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe PostBulkCreator do + include_context 'English locale' + it 'limits workers to two and keeps failures in their request slots' do actor = instance_double(User, id: 123) allow(User).to receive(:find).with(123) { @@ -9,6 +11,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 +31,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 +49,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) diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 71a19c1..e87b492 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, name:, category:) end before do @@ -10,6 +10,56 @@ 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 + english_name = create(:tag_name, name: 'shared_name', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name, category: :character) + japanese = create(:tag, name: 'temporary_japanese', category: :general) + # The DB permits this identity; validation has its own contract spec. + japanese.tag_name.update_columns(name: 'shared_name') + english_parent = create(:tag, name: 'english_parent', category: :material) + japanese_parent = create(:tag, name: 'japanese_parent', category: :general) + TagImplication.create!(tag: english, parent_tag: english_parent) + TagImplication.create!(tag: japanese, parent_tag: japanese_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: english_parent.name, category: :material }) + expect(plan[:post_tag_specs].pluck(:name)).not_to include(japanese_parent.name) + end + + it 'resolves aliases within the requested language' do + japanese = create(:tag, name: 'japanese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name, category: :character) + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + english_alias = create(:tag_name, :alias, name: 'temporary_alias', + canonical: english_name, + language_code: 'en', script_code: 'Latn') + english_alias.update_columns(name: 'shared_alias') + + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + canonical_id: english_name.id, 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, category: :character }]) + end + end + it 'plans direct and existing default tags without persisting records' do counts = [TagName.count, Tag.count] @@ -18,7 +68,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( @@ -31,7 +81,7 @@ RSpec.describe PostCreatePlan do it 'resolves aliases and keeps tag sections separate from canonical names' do canonical = create_tag!('虹夏', :character) - TagName.create!(name: 'にじか', canonical: canonical.tag_name) + create(:tag_name, :alias, name: 'にじか', canonical: canonical.tag_name) create_tag!('動画', :meta) plan = described_class.new( @@ -40,7 +90,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 +105,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 +123,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 +137,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..2fa36cc 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,34 @@ 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 + 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..5bc7bcc 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, 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/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb index 4a08de5..9351d75 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, 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..5d82b34 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, 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, 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..fa873ef --- /dev/null +++ b/backend/spec/support/locale_context.rb @@ -0,0 +1,13 @@ +RSpec.shared_context 'English locale' do + let!(:locale) do + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + unless Locale.exists?(code: 'en') + # Reference data only: do not generate names for unrelated existing tags. + Locale.insert_all!([ + { code: 'en', language_code: 'en', script_code: 'Latn', + name: 'English', created_at: Time.current }]) + end + Locale.find('en') + end +end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index b3c053f..e62a8f3 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) diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb index 20dd575..1934f66 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, name: 't1') + t2 = create(:tag, name: 't2') + t3 = create(:tag, name: 't3') + deprecated_tag = create(:tag, 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..fe293a6 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, name: 't1') + t2 = create(:tag, name: 't2') + t3 = create(:tag, name: 't3') + deprecated_tag = create(:tag, name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2") -- 2.53.0 From 701830b643d00416e85c37ffd9d4e5a87d690c54 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 27 Sep 2026 03:47:27 +0900 Subject: [PATCH 07/21] #55 --- .../app/controllers/materials_controller.rb | 4 +- backend/app/controllers/posts_controller.rb | 9 ++- backend/app/controllers/tags_controller.rb | 15 ++-- backend/app/models/tag.rb | 9 +-- backend/app/models/tag_name.rb | 44 ++++++----- .../app/models/tag_name_sanitisation_rule.rb | 45 ++++++----- backend/app/services/post_create_plan.rb | 59 +++++++------- backend/app/services/post_creator.rb | 18 +++-- backend/spec/db/delete_discarded_tags_spec.rb | 1 + backend/spec/factories/tag_names.rb | 9 --- backend/spec/models/locale_spec.rb | 8 +- .../models/tag_name_sanitisation_rule_spec.rb | 15 ++-- backend/spec/models/tag_name_spec.rb | 45 ++++++++--- backend/spec/models/tag_spec.rb | 49 ++++++------ .../spec/requests/locale_write_paths_spec.rb | 14 ++-- backend/spec/requests/posts_spec.rb | 8 +- backend/spec/requests/tags_spec.rb | 78 ++++++++++++------- .../spec/services/post_create_plan_spec.rb | 17 ++-- 18 files changed, 260 insertions(+), 187 deletions(-) diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 8bd5dae..837cb84 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -133,12 +133,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) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 228cb7b..4ca7a17 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -218,7 +218,7 @@ class PostsController < ApplicationController 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]) @@ -273,13 +273,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 が不正です.' diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index ffa5696..e906737 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -406,10 +406,10 @@ class TagsController < ApplicationController return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) - return unless validate_tag_rename(tag, name) - locale = resolve_locale! + return unless validate_tag_rename(locale, tag, name) + alias_names = params[:aliases].to_s.split.uniq parent_names = params[:parent_tags].to_s.split.uniq deprecated = bool?(:deprecated) @@ -455,6 +455,8 @@ class TagsController < ApplicationController 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) @@ -462,7 +464,7 @@ 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 @@ -684,7 +686,7 @@ class TagsController < ApplicationController created_by_user:) end - def validate_tag_rename tag, name + def validate_tag_rename locale, tag, name return true if name.blank? || name == tag.name if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) @@ -692,9 +694,8 @@ class TagsController < ApplicationController 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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 8a60314..2648097 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -135,7 +135,7 @@ class Tag < ApplicationRecord raise SectionLiteralParseError.new(raw_name, raw_name) end - name = TagName.canonicalise(name).first + 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? @@ -235,10 +235,7 @@ class Tag < ApplicationRecord end tag = tn.tag - if tag - tag.update!(tag_name_id: tn.id) if tag.tag_name_id != tn.id - return tag - end + return tag if tag tag = Tag.create!(tag_name: tn, category:) tn.update!(tag:) @@ -279,6 +276,8 @@ class Tag < ApplicationRecord end TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) + source_tag.tag_names.update_all(tag_id: source_tag.id, primary_key: false, + updated_at: Time.current) source_tag.destroy! source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 82be505..91081ee 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,24 +1,36 @@ class TagName < ApplicationRecord + 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 - def self.canonicalise names + def primary? = primary_flg + + def canonical = TagName.find_by(language:, tag:, primary_flg: true) + def aliases = TagName.where(language:, tag:, primary_flg: false) + + 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| + if tns[name].primary_flg + name + else + TagName.find_by(language_code: locale.language_code, + tag_id: tns[name].tag_id, + primary_flg: true).name + end + }.uniq end def self.generate_name locale, tag, name @@ -29,21 +41,15 @@ class TagName < ApplicationRecord private - def canonical_must_be_canonical - if canonical&.canonical_id? - errors.add :canonical, 'canonical は実体を示す必要があります.' - 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 diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index 37cde5f..1086bbb 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -15,31 +15,38 @@ 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 + 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/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index fedd574..6a0f9e6 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 + def build! locale + direct_tag_specs, tag_sections = parse_direct_tag_specs(locale) default_tag_specs = build_default_tag_specs(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) + 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,13 +42,13 @@ 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] + tag_name, category, sections = parse_raw_tag_name(locale, raw_name) + existing_tag = existing_tags_by_name(locale)[tag_name] raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? direct_tag_specs << { @@ -65,7 +65,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 +74,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 +87,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 } @@ -131,8 +131,10 @@ 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?) @@ -156,33 +158,33 @@ 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 + names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq Tag.joins(:tag_name).where(tag_names: { name: names }).index_by(&:name) 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!( + existing_tags_by_name(locale).merge!( Tag.joins(:tag_name) .where(tag_names: { name: missing_names }) .index_by(&:name)) 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 +262,13 @@ class PostCreatePlan end end - def resolved_tag_name name - tag_name = TagName.includes(:canonical).find_by(name:) - return name if tag_name.nil? - - (tag_name.canonical || tag_name).name + def resolved_tag_name locale, name + tag_name = TagName.find_by!(language_code: locale.language_code, name:) + unless tag_name.primary_flg + tag_name = TagName.find_by!(language_code: locale.language_code, + tag_id: tag_name.tag_id, + primary_flg: true) + end + tag_name.name end end diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index dbe4c89..b200901 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -21,14 +21,14 @@ 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 + 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) 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,13 +45,15 @@ 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_parent_post_ids locale + planned_create_attributes(locale)[:normalised_parent_post_ids] + end def planned_video_ms locale planned_create_attributes(locale)[:video_ms] 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/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index 4af5bcf..f4d6890 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -7,15 +7,6 @@ FactoryBot.define do trait :alias do primary_flg { false } - - after(:create) do |tag_name| - owner = tag_name.canonical&.tag - raise ArgumentError, 'alias requires a canonical tag' unless owner - - # The alias validation still rejects tag_id; its contract is tested separately. - tag_name.update_columns(tag_id: owner.id) - tag_name.association(:tag).reset - end end end end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index d87bb56..a3b328e 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -27,10 +27,10 @@ RSpec.describe Locale, type: :model do expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, language_code: 'fr', script_code: 'Latn', - primary_flg: true, canonical_id: nil) + primary_flg: true) expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(tag_id: second.id, language_code: 'fr', - script_code: 'Latn', primary_flg: true, canonical_id: nil) + script_code: 'Latn', primary_flg: true) expect(TagName.where(language_code: 'fr', primary_flg: true, tag_id: [first.id, second.id]).count).to eq(2) end @@ -53,13 +53,13 @@ RSpec.describe Locale, type: :model do prepare_french_reference! tag = create(:tag, name: 'alias_only_tag') alias_name = create(:tag_name, :alias, name: 'alias_fr', - canonical: tag.tag_name, + tag:, language_code: 'fr', script_code: 'Latn') create_french_locale! expect(alias_name.reload).to have_attributes( - canonical_id: tag.tag_name_id, tag_id: tag.id, + tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: false) generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 6fb9929..50d0a10 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -79,7 +79,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do end end - context 'when a conflicting canonical tag_name exists' do + context 'when a conflicting primary tag name exists' do let!(:existing) { create(:tag_name, name: 'foobar') } let!(:source) do create(:tag_name, name: 'tmp').tap do |tn| @@ -105,8 +105,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do 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(source_tag.reload.tag_name_id).to eq(existing.id) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) end end @@ -114,7 +113,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } let!(:alias_name) do - create(:tag_name, :alias, name: 'foobar', canonical: existing_tag.tag_name) + create(:tag_name, :alias, name: 'foobar', tag: existing_tag) end let!(:source) do create(:tag_name, name: 'tmp').tap do |tn| @@ -122,11 +121,15 @@ RSpec.describe TagNameSanitisationRule, type: :model do 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_tag.tag_name) expect(Tag.find(existing_tag.id)).to eq(existing_tag) end end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 66aa120..b8e251f 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -32,37 +32,58 @@ RSpec.describe TagName, type: :model do expect(tag.reload.tag_name.reload.tag_id).to eq(tag.id) expect(tag.tag_name.tag).to eq(tag) + expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true)) + .to eq(tag.tag_name) end - it 'accepts an alias owned by the canonical tag' do + it 'persists an alias owned by the same tag as its primary name' do tag = create(:tag) - alias_name = build(:tag_name, name: 'valid_alias', canonical: tag.tag_name, - tag:, primary_flg: false) + alias_name = create(:tag_name, :alias, name: 'valid_alias', tag:) - expect(alias_name).to have_attributes( - canonical_id: tag.tag_name_id, tag_id: tag.id, + expect(alias_name.reload).to have_attributes( + tag_id: tag.id, primary_flg: false, language_code: 'ja') - expect(alias_name).to be_valid + primary = described_class.find_by!(tag_id: alias_name.tag_id, + language_code: alias_name.language_code, + primary_flg: true) + expect(primary).to eq(tag.tag_name) end describe '.canonicalise' do + it 'resolves the primary name of the alias language on the same tag' do + tag = create(:tag, name: '日本語正本') + english_primary = create(:tag_name, tag:, name: 'english_primary', + language_code: 'en', script_code: 'Latn') + english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', + language_code: 'en', script_code: 'Latn') + representative_id = tag.tag_name_id + + primary = described_class.find_by!(tag_id: english_alias.tag_id, + language_code: english_alias.language_code, + primary_flg: true) + + expect(primary).to eq(english_primary) + expect(described_class.canonicalise(locale, [english_alias.name])) + .to eq([english_primary.name]) + expect(tag.reload.tag_name_id).to eq(representative_id) + end + it 'resolves only aliases in the requested language' do japanese = create(:tag, name: 'japanese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, tag_name: english_name) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) - english_alias = create(:tag_name, :alias, name: 'temporary_alias', - canonical: english_name, + tag: japanese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, language_code: 'en', script_code: 'Latn') - english_alias.update_columns(name: 'shared_alias') expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( - canonical_id: english_name.id, tag_id: english.id, + tag_id: english.id, primary_flg: false, language_code: 'en') expect(described_class.canonicalise(locale, ['shared_alias'])) .to eq(['english_canonical']) diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index e9683dd..13feb00 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -37,17 +37,16 @@ RSpec.describe Tag, type: :model do language_code: 'en', script_code: 'Latn') english = create(:tag, tag_name: english_name) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) - english_alias = create(:tag_name, :alias, name: 'temporary_alias', - canonical: english_name, + tag: japanese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, language_code: 'en', script_code: 'Latn') - english_alias.update_columns(name: 'shared_alias') expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( - canonical_id: english_name.id, tag_id: english.id, + tag_id: english.id, primary_flg: false, language_code: 'en') expect(described_class.normalise_tags!( english_locale, ['shared_alias'], @@ -270,12 +269,12 @@ RSpec.describe Tag, type: :model do expect(tag.category).to eq('character') end - it 'reuses the canonical tag for an alias without changing its category' do + it 'reuses the owning tag for an alias without changing its category' do tag = create(:tag, category: :character) representative_id = tag.tag_name_id - alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical: tag.tag_name) + alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:) expect(alias_name.reload).to have_attributes( - canonical_id: tag.tag_name_id, tag_id: tag.id, + tag_id: tag.id, primary_flg: false, language_code: 'ja') found = nil @@ -289,14 +288,14 @@ RSpec.describe Tag, type: :model do expect(tag.reload.tag_name_id).to eq(representative_id) end - it 'reuses the canonical tag through another alias' do + it 'reuses the owning tag through another alias' do tag = create(:tag) - canonical = tag.tag_name - alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical:) - other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', canonical:) + primary = tag.tag_name + 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( - canonical_id: canonical.id, tag_id: tag.id, + tag_id: tag.id, primary_flg: false, language_code: 'ja')) expect { @@ -305,7 +304,7 @@ RSpec.describe Tag, type: :model do 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(tag.reload.tag_name).to eq(primary) end end @@ -329,7 +328,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 @@ -355,7 +355,8 @@ 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 @@ -363,7 +364,7 @@ RSpec.describe Tag, type: :model do 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 = create(:tag_name, :alias, name: 'merge_alias', canonical: source_tag_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 @@ -433,7 +434,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 @@ -464,7 +466,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 @@ -493,7 +496,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) @@ -502,7 +506,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/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index f90a8c3..7d269dc 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -202,17 +202,19 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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', canonical_id: english_name.id, + script_code: 'Latn', tag_id: tag.id, primary_flg: false, language_code: 'en') expect(tag.reload.tag_name_id).to eq(english_name.id) + primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) + expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn') end it 'does not take an alias from another language' do japanese = create(:tag, name: 'japanese_owner') japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) + tag: japanese) expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') put "/tags/#{ tag.id }", params: { @@ -221,12 +223,14 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:ok), response.body expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(TagName.find_by!(language_code: 'en', name: 'shared_alias')) .to have_attributes( - canonical_id: english_name.id, tag_id: tag.id, + tag_id: tag.id, script_code: 'Latn', primary_flg: false, language_code: 'en') + expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id)) + .to contain_exactly(['ja', japanese.id], ['en', tag.id]) end end end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 585dacb..580271a 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -129,7 +129,7 @@ RSpec.describe 'Posts API', type: :request do let!(:tag) { create(:tag, tag_name:, category: :general) } let!(:tag_name2) { create(:tag_name, name: 'unko') } let!(:tag2) { create(:tag, tag_name: tag_name2, category: :deerjikist) } - let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', canonical: tag_name) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', tag:) } let!(:hit_post) do Post.create!(uploaded_user: user, title: 'hello spec world', @@ -351,7 +351,7 @@ RSpec.describe 'Posts API', type: :request do let!(:baz_tag) { create(:tag, tag_name: baz_tag_name, category: :general) } let!(:foo_alias_tag_name) do - create(:tag_name, :alias, 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 @@ -854,7 +854,7 @@ RSpec.describe 'Posts API', type: :request do 15.times.map do |i| tag_name = create(:tag_name, name: "show_query_tag_#{ i }") tag = create(:tag, tag_name:, category: :general) - create(:tag_name, :alias, name: "show_query_alias_#{ i }", canonical: tag_name) + create(:tag_name, :alias, name: "show_query_alias_#{ i }", tag:) PostTag.create!(post: post_record, tag:) tag end @@ -999,7 +999,7 @@ RSpec.describe 'Posts API', type: :request do describe 'POST /posts' do let(:member) { create(:user, :member) } - let!(:alias_tag_name) { create(:tag_name, :alias, 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 diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 26c1b52..8d10525 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe 'Tags API', type: :request do let!(:tn) { create(:tag_name, name: 'spec_tag') } let!(:tag) { create(:tag, tag_name: tn, category: :general) } - let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', canonical: tn) } + 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) { create(:tag_name, name: 'unknown') } @@ -466,7 +466,7 @@ RSpec.describe 'Tags API', type: :request do it 'excludes external tags but preserves internal alias matches when nico is false' do internal = create(:tag, category: :general, name: 'switch_internal', post_count: 1) alias_target = create(:tag, category: :general, name: 'alias_target', post_count: 1) - create(:tag_name, :alias, name: 'switch_alias', canonical: alias_target.tag_name) + 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' } @@ -479,13 +479,13 @@ RSpec.describe 'Tags API', type: :request do 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" create(:tag, category: :general, name: literal, post_count: 1) alias_target = create( :tag, category: :general, name: 'literal_alias_target', post_count: 1) create(:tag_name, :alias, name: "#{ literal }_alias", - canonical: alias_target.tag_name) + tag: alias_target) create(:external_tag, name: literal, post_count: 1) create(:tag, category: :general, name: 'literalXmatch', post_count: 2) create(:external_tag, name: 'literalXmatch', post_count: 2) @@ -509,7 +509,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) @@ -772,7 +772,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' } @@ -781,8 +782,10 @@ 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(tag.tag_name).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 @@ -801,7 +804,8 @@ RSpec.describe 'Tags API', type: :request do ) expect(tag.reload.name).to eq('spec_tag') - expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko') + 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(tag.tag_name) end @@ -1170,14 +1174,17 @@ RSpec.describe 'Tags API', type: :request do expect(tag.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') 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', @@ -1212,7 +1219,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 @@ -1283,10 +1291,14 @@ 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(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 @@ -1395,7 +1407,7 @@ 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, @@ -1429,7 +1441,8 @@ RSpec.describe 'Tags API', type: :request do 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(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) @@ -1443,9 +1456,11 @@ RSpec.describe 'Tags API', type: :request do category: :general) stolen_alias = create(:tag_name, :alias, name: 'put_stolen_alias', - canonical: old_owner.tag_name) + 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: { @@ -1461,8 +1476,11 @@ 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) @@ -1479,7 +1497,7 @@ RSpec.describe 'Tags API', type: :request do category: :general) stolen_alias = create(:tag_name, :alias, name: 'put_alias_collision_name', - canonical: old_owner.tag_name) + tag: old_owner) wiki_page = Wiki::Commit.create_content!( tag_name: tag.tag_name, @@ -1502,9 +1520,13 @@ RSpec.describe 'Tags API', type: :request do 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(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) diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index e87b492..209def7 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -40,17 +40,16 @@ RSpec.describe PostCreatePlan do language_code: 'en', script_code: 'Latn') english = create(:tag, tag_name: english_name, category: :character) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - canonical: japanese.tag_name) - english_alias = create(:tag_name, :alias, name: 'temporary_alias', - canonical: english_name, + tag: japanese) + english_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: english, language_code: 'en', script_code: 'Latn') - english_alias.update_columns(name: 'shared_alias') expect(japanese_alias.reload).to have_attributes( - canonical_id: japanese.tag_name_id, tag_id: japanese.id, + tag_id: japanese.id, primary_flg: false, language_code: 'ja') expect(english_alias.reload).to have_attributes( - canonical_id: english_name.id, tag_id: english.id, + tag_id: english.id, primary_flg: false, language_code: 'en') plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) @@ -79,9 +78,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) - create(:tag_name, :alias, 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( -- 2.53.0 From c912bebbeec67b911c2fca8da78d34e76b176dda Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 27 Sep 2026 15:07:24 +0900 Subject: [PATCH 08/21] #55 --- backend/app/controllers/posts_controller.rb | 16 +- backend/app/controllers/tags_controller.rb | 30 +-- backend/app/models/tag.rb | 9 +- backend/app/models/tag_name.rb | 6 +- backend/app/services/post_create_plan.rb | 15 +- backend/app/services/post_creator.rb | 6 +- backend/spec/factories/tags.rb | 21 +- backend/spec/models/locale_spec.rb | 16 +- .../spec/models/material_export_item_spec.rb | 2 +- backend/spec/models/post_spec.rb | 2 +- backend/spec/models/post_version_spec.rb | 2 +- backend/spec/models/tag_implication_spec.rb | 10 +- .../models/tag_name_sanitisation_rule_spec.rb | 25 +- backend/spec/models/tag_name_spec.rb | 35 ++- backend/spec/models/tag_spec.rb | 94 ++++---- backend/spec/models/version_record_spec.rb | 6 +- backend/spec/requests/deerjikists_spec.rb | 2 +- backend/spec/requests/error_responses_spec.rb | 2 +- .../spec/requests/gekanator_learning_spec.rb | 8 +- backend/spec/requests/gekanator_posts_spec.rb | 4 +- .../spec/requests/locale_write_paths_spec.rb | 27 ++- backend/spec/requests/materials_spec.rb | 46 ++-- backend/spec/requests/nico_tags_spec.rb | 14 +- backend/spec/requests/posts_spec.rb | 111 +++++---- backend/spec/requests/tag_versions_spec.rb | 16 +- .../tag_wiki_history_integrity_spec.rb | 12 +- .../spec/requests/tags_deerjikists_spec.rb | 4 +- backend/spec/requests/tags_spec.rb | 219 +++++++++--------- backend/spec/requests/theatres_spec.rb | 4 +- backend/spec/requests/wiki_spec.rb | 9 +- .../services/material_sync_importer_spec.rb | 2 +- .../nico_tag_version_recorder_spec.rb | 2 +- .../spec/services/post_create_plan_spec.rb | 26 ++- .../services/post_import_previewer_spec.rb | 2 +- .../services/wiki/commit_integrity_spec.rb | 2 +- backend/spec/services/wiki/commit_spec.rb | 4 +- backend/spec/support/test_records.rb | 4 + backend/spec/tasks/nico_sync_spec.rb | 19 +- .../spec/tasks/post_similarity_calc_spec.rb | 8 +- .../spec/tasks/tag_similarity_calc_spec.rb | 8 +- 40 files changed, 473 insertions(+), 377 deletions(-) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 4ca7a17..6b1c633 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,7 +48,7 @@ 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, @@ -102,8 +104,10 @@ 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 }] }]) @@ -394,19 +398,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 diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index e906737..d8cbcd4 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -430,7 +430,7 @@ class TagsController < ApplicationController 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.delete(name) @@ -701,13 +701,13 @@ class TagsController < ApplicationController 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? + unless target_tag_name current_tag_name.update!(name:) return end @@ -722,19 +722,21 @@ class TagsController < ApplicationController TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) end - promoted_tag_name.update!(canonical: nil) + promoted_tag_name.update!(canonical_id: nil, primary_flg: true) - TagName.where(canonical_id: current_tag_name.id) + TagName.where(tag:, + language_code: current_tag_name.language.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, primary_flg: false) end - current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name) - tag.update!(tag_name: promoted_tag_name) + current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) + 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, primary_flg: false) return unless old_owner_tag && old_owner_tag != tag @@ -766,12 +768,12 @@ class TagsController < ApplicationController 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) 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.update!(canonical_id: tag.tag_name.id) end affected_tags.compact.uniq.each do |affected_tag| diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 2648097..a8cdfd6 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -51,12 +51,10 @@ class Tag < ApplicationRecord has_many :tag_versions has_many :tag_names - belongs_to :tag_name delegate :wiki_page, to: :tag_name attribute :version_no, :integer, default: 1 - delegate :name, to: :tag_name, allow_nil: true validates :tag_name, presence: true enum :category, deerjikist: 'deerjikist', @@ -84,9 +82,8 @@ class Tag < ApplicationRecord 'mtr:' => :material, 'meta:' => :meta }.freeze - def name= val - (self.tag_name ||= build_tag_name).name = val - end + def tag_name(language_code) = tag_names.find_by(language_code:, primary_flg: true) + def name(language_code) = tag_name(language_code)&.name def deprecated? = deprecated_at? @@ -276,7 +273,7 @@ class Tag < ApplicationRecord end TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) - source_tag.tag_names.update_all(tag_id: source_tag.id, primary_key: false, + source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, updated_at: Time.current) source_tag.destroy! diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 91081ee..046cc27 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -13,8 +13,8 @@ class TagName < ApplicationRecord def primary? = primary_flg - def canonical = TagName.find_by(language:, tag:, primary_flg: true) - def aliases = TagName.where(language:, tag:, primary_flg: false) + def canonical = TagName.find_by(language_code:, tag_id:, primary_flg: true) + def aliases = TagName.where(language_code:, tag_id:, primary_flg: false) def self.canonicalise locale, names names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) @@ -23,7 +23,7 @@ class TagName < ApplicationRecord tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) names.map { |name| - if tns[name].primary_flg + if !(tns[name]) || tns[name].primary? name else TagName.find_by(language_code: locale.language_code, diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index 6a0f9e6..e1279a1 100644 --- a/backend/app/services/post_create_plan.rb +++ b/backend/app/services/post_create_plan.rb @@ -10,7 +10,7 @@ class PostCreatePlan def build! locale direct_tag_specs, tag_sections = parse_direct_tag_specs(locale) - default_tag_specs = build_default_tag_specs(direct_tag_specs) + 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) @@ -263,12 +263,11 @@ class PostCreatePlan end def resolved_tag_name locale, name - tag_name = TagName.find_by!(language_code: locale.language_code, name:) - unless tag_name.primary_flg - tag_name = TagName.find_by!(language_code: locale.language_code, - tag_id: tag_name.tag_id, - primary_flg: true) - end - tag_name.name + tag_name = TagName.find_by(language_code: locale.language_code, name:) + return name if !(tag_name) || tag_name.primary? + + TagName.find_by!(language_code: locale.language_code, + tag_id: tag_name.tag_id, + primary_flg: true).name end end diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index b200901..f328068 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -74,13 +74,13 @@ class PostCreator 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! + def build_materialised_plan locale + plan = PostCreatePlan.new(attributes: @attributes).build!(locale) snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || []) post_tags = materialise_tags(plan[:post_tag_specs] || []) { diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index a9df735..b362e23 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -1,20 +1,29 @@ 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 - after(:create) do |tag| - tag.tag_name.update!(tag:) if tag.tag_name.tag_id != tag.id + 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) end end end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index a3b328e..54304bf 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -13,17 +13,19 @@ RSpec.describe Locale, type: :model do end it 'generates a primary name for every existing tag using the name generator' do - first = create(:tag, name: 'first_existing_tag') - second = create(:tag, name: 'second_existing_tag') + 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, first.name) + .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja').name) .and_return('name_from_generator') locale = create_french_locale! - expect(TagName).to have_received(:generate_name).with(locale, first, first.name) - expect(TagName).to have_received(:generate_name).with(locale, second, second.name) + expect(TagName).to have_received(:generate_name) + .with(locale, first, primary_tag_name_for(first, 'ja').name) + expect(TagName).to have_received(:generate_name) + .with(locale, second, primary_tag_name_for(second, 'ja').name) expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, language_code: 'fr', script_code: 'Latn', @@ -37,7 +39,7 @@ RSpec.describe Locale, type: :model do it 'does not duplicate an existing primary name in the new language' do prepare_french_reference! - tag = create(:tag, name: 'already_named') + tag = create(:tag, primary_name: 'already_named') existing = create(:tag_name, name: 'nom_existant', tag:, language_code: 'fr', script_code: 'Latn') @@ -51,7 +53,7 @@ RSpec.describe Locale, type: :model do it 'creates a primary name when the language has only an alias' do prepare_french_reference! - tag = create(:tag, name: 'alias_only_tag') + tag = create(:tag, primary_name: 'alias_only_tag') alias_name = create(:tag_name, :alias, name: 'alias_fr', tag:, language_code: 'fr', script_code: 'Latn') diff --git a/backend/spec/models/material_export_item_spec.rb b/backend/spec/models/material_export_item_spec.rb index 07e08f3..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) { create(:tag, 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 ccbb20f..f29b530 100644 --- a/backend/spec/models/post_version_spec.rb +++ b/backend/spec/models/post_version_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe PostVersion, type: :model do let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') } - let!(:tag) { create(:tag, tag_name: tag_name, category: :general) } + 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/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 50d0a10..558869e 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -50,17 +50,17 @@ RSpec.describe TagNameSanitisationRule, type: :model do end it 'renames without merging tags or deleting either language identity' do - japanese = create(:tag, name: 'foobar') + japanese = create(:tag, primary_name: 'foobar') english_name = create(:tag_name, name: 'temporary', language_code: 'en', script_code: 'Latn') - english = create(:tag, tag_name: english_name) + 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(japanese.reload.tag_name).to have_attributes( + expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes( name: 'foobar', language_code: 'ja', tag_id: japanese.id) expect(english.reload.tag_name_id).to eq(english_name.id) end @@ -96,15 +96,19 @@ RSpec.describe TagNameSanitisationRule, type: :model do context 'when the source tag_name has a tag and the existing one has no tag' do let!(:existing) { create(:tag_name, name: 'foobar') } - let!(:source_tag) { create(:tag, name: 'tmp', category: :general) } - let!(:source_tag_name_id) { source_tag.tag_name_id } + 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! + 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(source_tag.reload.tag_name_id).to eq(existing.id) expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) end @@ -112,6 +116,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } + let!(:existing_primary) { primary_tag_name_for(existing_tag, 'ja') } let!(:alias_name) do create(:tag_name, :alias, name: 'foobar', tag: existing_tag) end @@ -129,17 +134,17 @@ RSpec.describe TagNameSanitisationRule, type: :model do 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_tag.tag_name) + .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) { create(:tag_name, name: 'foobar') } - let!(:existing_tag) { create(:tag, tag_name: existing_tn, category: :general) } + let!(:existing_tag) { create(:tag, primary_tag_name: existing_tn, category: :general) } let!(:source_tn) { create(:tag_name, name: 'tmp') } - let!(:source_tag) { create(:tag, tag_name: source_tn, category: :general) } + let!(:source_tag) { create(:tag, primary_tag_name: source_tn, category: :general) } let!(:source_tag_name_id) { source_tn.id } before do @@ -155,7 +160,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 index b8e251f..37107cc 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -28,16 +28,33 @@ RSpec.describe TagName, type: :model do end it 'persists both sides of the representative tag factory association' do - tag = create(:tag) + name = create(:tag_name) + tag = create(:tag, primary_tag_name: name) - expect(tag.reload.tag_name.reload.tag_id).to eq(tag.id) - expect(tag.tag_name.tag).to eq(tag) + 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(tag.tag_name) + .to eq(name) + expect(tag.reload.tag_name_id).to eq(name.id) + end + + it 'builds a primary name without persisting the tag or its name' do + tag = nil + + expect { + tag = build(:tag, primary_name: 'built_primary') + }.to change(Tag, :count).by(0).and change(described_class, :count).by(0) + + expect(tag).to be_new_record + expect(tag.tag_names.target).to contain_exactly(have_attributes( + name: 'built_primary', language_code: 'ja', script_code: 'Jpan', + primary_flg: true)) end it 'persists an alias owned by the same tag as its primary name' do - tag = create(:tag) + 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( @@ -46,12 +63,12 @@ RSpec.describe TagName, type: :model do primary = described_class.find_by!(tag_id: alias_name.tag_id, language_code: alias_name.language_code, primary_flg: true) - expect(primary).to eq(tag.tag_name) + expect(primary).to eq(primary_name) end describe '.canonicalise' do it 'resolves the primary name of the alias language on the same tag' do - tag = create(:tag, name: '日本語正本') + tag = create(:tag, primary_name: '日本語正本') english_primary = create(:tag_name, tag:, name: 'english_primary', language_code: 'en', script_code: 'Latn') english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', @@ -69,10 +86,10 @@ RSpec.describe TagName, type: :model do end it 'resolves only aliases in the requested language' do - japanese = create(:tag, name: 'japanese_canonical') + japanese = create(:tag, primary_name: 'japanese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') - english = create(:tag, tag_name: english_name) + english = create(:tag, primary_tag_name: english_name) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: japanese) english_alias = create(:tag_name, :alias, name: 'shared_alias', diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 13feb00..d900053 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -4,7 +4,7 @@ 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 @@ -32,10 +32,10 @@ RSpec.describe Tag, type: :model do { code: 'en', language_code: 'en', script_code: 'Latn', name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en') english_locale = Locale.find('en') - japanese = create(:tag, name: 'japanese_canonical') + japanese = create(:tag, primary_name: 'japanese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') - english = create(:tag, tag_name: english_name) + english = create(:tag, primary_tag_name: english_name) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: japanese) english_alias = create(:tag_name, :alias, name: 'shared_alias', @@ -59,16 +59,16 @@ RSpec.describe Tag, type: :model do it 'rejects deprecated tags when deny_deprecated is enabled' do tag_name = create(:tag_name, name: 'normalise deprecated tag') deprecated_tag = create(:tag, - tag_name:, + primary_tag_name: tag_name, category: :general, deprecated_at: 1.day.from_now) expect { described_class.normalise_tags!(Locale.nipponese, - [deprecated_tag.name], + [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 @@ -85,7 +85,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[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 @@ -94,7 +96,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[-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 @@ -103,7 +107,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[-]'], 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 @@ -112,7 +118,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[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 @@ -121,7 +129,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[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 @@ -130,7 +140,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[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 @@ -139,7 +151,9 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[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 @@ -148,25 +162,23 @@ RSpec.describe Tag, type: :model do ['伊地知ニジカ[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) @@ -183,8 +195,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!( @@ -210,7 +222,7 @@ RSpec.describe Tag, type: :model do tag = described_class.find_or_create_by_tag_name!( locale, 'english_name', category: :character) - expect(tag.tag_name.reload).to have_attributes( + 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') @@ -223,8 +235,10 @@ RSpec.describe Tag, type: :model do locale, 'same_name', category: :character) expect(english).not_to eq(japanese) - expect(english.tag_name.language_code).to eq('en') - expect(japanese.tag_name.language_code).to eq('ja') + expect(primary_tag_name_for(english, 'en')).to have_attributes( + name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true) + expect(primary_tag_name_for(japanese, 'ja')).to have_attributes( + name: 'same_name', tag_id: japanese.id, language_code: 'ja', primary_flg: true) expect(described_class.find_or_create_by_tag_name!( locale, 'same_name', category: :general)).to eq(english) expect(described_class.find_or_create_by_tag_name!( @@ -232,7 +246,7 @@ RSpec.describe Tag, type: :model do end it 'preserves the V1 representative when looking up another primary language' do - tag = create(:tag, name: '日本語代表名') + tag = create(:tag, primary_name: '日本語代表名') representative_id = tag.tag_name_id english = create(:tag_name, name: 'english_primary', tag:, language_code: 'en', script_code: 'Latn') @@ -251,7 +265,7 @@ RSpec.describe Tag, type: :model do with_tagme: false, with_no_deerjikist: false) expect(tags.length).to eq(1) - expect(tags.first.tag_name).to have_attributes( + 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 @@ -265,7 +279,7 @@ RSpec.describe Tag, type: :model do ' 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') end @@ -290,7 +304,7 @@ RSpec.describe Tag, type: :model do it 'reuses the owning tag through another alias' do tag = create(:tag) - primary = tag.tag_name + 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:) @@ -304,14 +318,14 @@ RSpec.describe Tag, type: :model do expect(found).to eq(tag) }.to change(Tag, :count).by(0).and change(TagName, :count).by(0) - expect(tag.reload.tag_name).to eq(primary) + 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') @@ -363,7 +377,7 @@ RSpec.describe Tag, type: :model do 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_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 @@ -380,7 +394,7 @@ RSpec.describe Tag, type: :model do 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]) + expect(target_versions.last.aliases.split).to contain_exactly('merge_alias', source_name) end it 'deletes source relationships while preserving unrelated relationships' do @@ -486,7 +500,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) diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index e76f4e7..32a28ed 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,7 +9,7 @@ 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: '', parent_tag_ids: '', @@ -27,7 +27,7 @@ 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: '', parent_tag_ids: '', 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 855bb75..7154683 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -898,22 +898,22 @@ RSpec.describe 'Gekanator learning API', type: :request do describe 'GET /gekanator/questions' do it 'omits questions for deprecated tags' do - active_tag = create(:tag, name: 'active_question_tag', category: :general) + active_tag = create(:tag, primary_name: 'active_question_tag', category: :general) deprecated_tag = create(:tag, - name: 'deprecated_question_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 7fe627a..8cfa46f 100644 --- a/backend/spec/requests/gekanator_posts_spec.rb +++ b/backend/spec/requests/gekanator_posts_spec.rb @@ -4,9 +4,9 @@ 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 = create(:tag, name: 'active tag', category: :general) + active_tag = create(:tag, primary_name: 'active tag', category: :general) deprecated_tag = create(:tag, - name: 'deprecated tag', + primary_name: 'deprecated tag', category: :general, deprecated_at: Time.current) post_record = Post.create!(title: 'source', url: 'https://example.com/source') diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index 7d269dc..6c0ef8f 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -96,7 +96,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do tag = create(:tag) put "/tags/#{ tag.id }", params: locale_params.merge( - name: tag.name, category: tag.category, deprecated: false, + 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 @@ -110,11 +110,11 @@ RSpec.describe 'Locale propagation on write paths', type: :request do let!(:english_name) do create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') end - let!(:english_tag) { create(:tag, tag_name: english_name, category: :material) } + let!(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) } let!(:japanese_tag) do - create(:tag, name: 'temporary_japanese', category: :general).tap do |tag| + create(:tag, primary_name: 'temporary_japanese', category: :general).tap do |tag| # Isolate lookup from the separately tested uniqueness validation. - tag.tag_name.update_columns(name: 'shared_name') + primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name') end end @@ -157,7 +157,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do child = create(:tag) put "/tags/#{ child.id }", params: { - locale: 'en', name: child.name, category: child.category, deprecated: false, + 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 @@ -180,23 +181,24 @@ RSpec.describe 'Locale propagation on write paths', type: :request do let!(:english_name) do create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn') end - let!(:tag) { create(:tag, tag_name: english_name) } + let!(:tag) { create(:tag, primary_tag_name: english_name) } it 'allows a rename to a name already used in Japanese' do - japanese = create(:tag, name: 'rename_target') + japanese = create(:tag, primary_name: 'rename_target') patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' } expect(response).to have_http_status(:ok), response.body - expect(tag.reload.tag_name).to have_attributes( + expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes( name: 'rename_target', language_code: 'en', tag_id: tag.id) - expect(japanese.reload.tag_name).to have_attributes( + expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes( name: 'rename_target', language_code: 'ja', tag_id: japanese.id) end it 'creates aliases in the requested language' do put "/tags/#{ tag.id }", params: { - locale: 'en', name: tag.name, category: tag.category, deprecated: false, + 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 @@ -210,7 +212,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end it 'does not take an alias from another language' do - japanese = create(:tag, name: 'japanese_owner') + japanese = create(:tag, primary_name: 'japanese_owner') japanese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: japanese) expect(japanese_alias.reload).to have_attributes( @@ -218,7 +220,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do primary_flg: false, language_code: 'ja') put "/tags/#{ tag.id }", params: { - locale: 'en', name: tag.name, category: tag.category, deprecated: false, + 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 diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index 17ff0a9..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 - create(:tag, name: 'material_index_a', category: :material) + create(:tag, primary_name: 'material_index_a', category: :material) end let!(:tag_b) do - create(:tag, 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 = - create(:tag, name: 'material_scope_root', + create(:tag, primary_name: 'material_scope_root', category: :material) child_b = - create(:tag, name: 'material_scope_b', + create(:tag, primary_name: 'material_scope_b', category: :material) child_a = - create(:tag, name: 'material_scope_a', + create(:tag, primary_name: 'material_scope_a', category: :material) deprecated = - create(:tag, name: 'material_scope_old', + create(:tag, primary_name: 'material_scope_old', category: :material, deprecated_at: Time.current) grandchild = - create(:tag, 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 - create(:tag, 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') @@ -324,7 +325,7 @@ RSpec.describe 'Materials API', type: :request do it 'returns 422 when the existing tag is not material/character' do general_tag_name = create(:tag_name, name: 'material_create_general_tag') - create(:tag, tag_name: general_tag_name, category: :general) + 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 - create(:tag, 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 = - create(:tag, 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 = - create(:tag, 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) { create(:tag, name: 'zip_a', category: :material) } - let!(:tag_b) { create(:tag, 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 - create(:tag, 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 - create(:tag, 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 f800afb..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) @@ -163,17 +163,17 @@ RSpec.describe 'NicoTags', type: :request do nico_tag = create(:external_tag, name: 'nico_tags_spec_source') linked_a_name = create(:tag_name, name: 'nico_linked_a') - linked_a = create(:tag, tag_name: linked_a_name, category: :general) + linked_a = create(:tag, primary_tag_name: linked_a_name, category: :general) linked_b_name = create(:tag_name, name: 'nico_linked_b') - linked_b = create(:tag, tag_name: linked_b_name, category: :general) + 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 580271a..38bc115 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -114,7 +114,7 @@ RSpec.describe 'Posts API', type: :request do end let!(:tag_name) { create(:tag_name, name: 'spec_tag') } - let!(:tag) { create(:tag, tag_name: tag_name, category: :general) } + 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| @@ -126,9 +126,9 @@ RSpec.describe 'Posts API', type: :request do let!(:user) { create_member_user! } let!(:tag_name) { create(:tag_name, name: 'spec_tag') } - let!(:tag) { create(:tag, tag_name:, category: :general) } + let!(:tag) { create(:tag, primary_tag_name: tag_name, category: :general) } let!(:tag_name2) { create(:tag_name, name: 'unko') } - let!(:tag2) { create(:tag, tag_name: tag_name2, category: :deerjikist) } + 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 @@ -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) @@ -342,13 +347,13 @@ RSpec.describe 'Posts API', type: :request do context 'when tags contain not:' do let!(:foo_tag_name) { create(:tag_name, name: 'not_spec_foo') } - let!(:foo_tag) { create(:tag, tag_name: foo_tag_name, category: :general) } + let!(:foo_tag) { create(:tag, primary_tag_name: foo_tag_name, category: :general) } let!(:bar_tag_name) { create(:tag_name, name: 'not_spec_bar') } - let!(:bar_tag) { create(:tag, tag_name: bar_tag_name, category: :general) } + let!(:bar_tag) { create(:tag, primary_tag_name: bar_tag_name, category: :general) } let!(:baz_tag_name) { create(:tag_name, name: 'not_spec_baz') } - let!(:baz_tag) { create(:tag, tag_name: baz_tag_name, category: :general) } + let!(:baz_tag) { create(:tag, primary_tag_name: baz_tag_name, category: :general) } let!(:foo_alias_tag_name) do create(:tag_name, :alias, name: 'not_spec_foo_alias', tag: foo_tag) @@ -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 @@ -773,7 +780,7 @@ RSpec.describe 'Posts API', type: :request do it 'omits deprecated tags' do deprecated_tag = create(:tag, - name: 'deprecated_post_tag', + primary_name: 'deprecated_post_tag', category: :general, deprecated_at: Time.current) PostTag.create!(post: post_record, tag: deprecated_tag) @@ -853,7 +860,7 @@ RSpec.describe 'Posts API', type: :request do tags = 15.times.map do |i| tag_name = create(:tag_name, name: "show_query_tag_#{ i }") - tag = create(:tag, tag_name:, category: :general) + 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 @@ -909,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') @@ -1157,7 +1166,7 @@ RSpec.describe 'Posts API', type: :request do it 'rejects a deprecated tag specified directly' do create(:tag, - name: 'deprecated_direct_tag', + primary_name: 'deprecated_direct_tag', category: :general, deprecated_at: Time.current) sign_in_as(member) @@ -1176,16 +1185,16 @@ RSpec.describe 'Posts API', type: :request do end it 'expands through multiple deprecated parent tags and saves active ancestors' do - child = create(:tag, name: 'active_child', category: :general) + child = create(:tag, primary_name: 'active_child', category: :general) deprecated_parent = create(:tag, - name: 'deprecated_parent', + primary_name: 'deprecated_parent', category: :general, deprecated_at: Time.current) deprecated_grandparent = create(:tag, - name: 'deprecated_grandparent', + primary_name: 'deprecated_grandparent', category: :general, deprecated_at: Time.current) - active_grandparent = create(:tag, name: 'active_grandparent', category: :general) + 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) @@ -1199,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 @@ -1302,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) @@ -1322,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 @@ -1339,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 @@ -1656,7 +1669,7 @@ RSpec.describe 'Posts API', type: :request do begin_ms: 1000, end_ms: 2000) tn2 = create(:tag_name, name: 'spec_tag_2') - replacement_tag = create(:tag, tag_name: tn2, category: :general) + replacement_tag = create(:tag, primary_tag_name: tn2, category: :general) put "/posts/#{post_record.id}", params: post_update_params( post_record, @@ -1705,7 +1718,7 @@ RSpec.describe 'Posts API', type: :request do it 'rejects a deprecated tag specified directly' do create(:tag, - name: 'deprecated_update_tag', + primary_name: 'deprecated_update_tag', category: :general, deprecated_at: Time.current) sign_in_as(member) @@ -1962,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) @@ -1994,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) @@ -2024,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 @@ -2055,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 @@ -2077,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 = @@ -2112,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 @@ -2137,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,7 +2210,7 @@ RSpec.describe 'Posts API', type: :request do let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) } let!(:tag_name2) { create(:tag_name, name: 'spec_tag_2') } - let!(:tag2) { create(:tag, tag_name: tag_name2, category: :general) } + let!(:tag2) { create(:tag, primary_tag_name: tag_name2, category: :general) } def snapshot_tags(post) post.snapshot_tag_names.join(' ') @@ -2416,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,7 +2569,7 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record) tag_name2 = create(:tag_name, name: 'spec_tag_2') - create(:tag, tag_name: tag_name2, category: :general) + create(:tag, primary_tag_name: tag_name2, category: :general) expect do put "/posts/#{post_record.id}", params: post_write_params( @@ -2715,7 +2734,7 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) tag_name2 = create(:tag_name, name: 'spec_tag_2') - tag2 = create(:tag, tag_name: tag_name2, category: :general) + 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 f9802a4..4aa441b 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) } @@ -211,7 +211,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 } @@ -233,7 +233,7 @@ RSpec.describe 'TagVersions API', type: :request do it 'does not create tag versions by wiki updates when tag has no versions yet' do wiki_tag_name = create(:tag_name, name: 'tag_versions_from_wiki') - wiki_tag = create(:tag, tag_name: wiki_tag_name, category: :general) + 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 33401e9..a464c05 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -9,12 +9,12 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do def create_tag! name:, category: :general tag_name = create(:tag_name, name:) - create(:tag, tag_name:, category:) + 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 8d10525..07478a0 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -3,12 +3,12 @@ require 'rails_helper' RSpec.describe 'Tags API', type: :request do let!(:tn) { create(:tag_name, name: 'spec_tag') } - let!(:tag) { create(:tag, tag_name: tn, category: :general) } + 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) { create(:tag_name, name: 'unknown') } - let!(:tag2) { create(:tag, tag_name: tn2, category: :general) } + let!(:tag2) { create(:tag, primary_tag_name: tn2, category: :general) } def response_tags json.fetch('tags') @@ -38,7 +38,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 +93,7 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by category' do - meme = create(:tag, name: 'meme_only', category: :meme) + meme = create(:tag, primary_name: 'meme_only', category: :meme) get '/tags', params: { category: 'meme' } @@ -102,28 +104,28 @@ RSpec.describe 'Tags API', type: :request do it 'filters tags by deprecated state' do deprecated_tag = create(:tag, - name: 'deprecated_filter', + primary_name: 'deprecated_filter', category: :general, deprecated_at: 1.day.from_now) - active_tag = create(:tag, name: 'active_filter', category: :general) + 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 = create(:tag, name: 'pc_low', category: :general) - mid = create(:tag, name: 'pc_mid', category: :general) - high = create(:tag, 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) @@ -141,8 +143,8 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by created_at range' do - old_tag = create(:tag, name: 'created_old', category: :general) - new_tag = create(:tag, 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) @@ -161,8 +163,8 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by updated_at range' do - old_tag = create(:tag, name: 'updated_old', category: :general) - new_tag = create(:tag, 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) @@ -181,12 +183,12 @@ RSpec.describe 'Tags API', type: :request do end it 'orders tags by custom category order' do - create(:tag, name: 'cat_deerjikist', category: :deerjikist) - create(:tag, name: 'cat_meme', category: :meme) - create(:tag, name: 'cat_character', category: :character) - create(:tag, name: 'cat_general', category: :general) - create(:tag, name: 'cat_material', category: :material) - create(:tag, 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 } @@ -201,19 +203,19 @@ 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'), + 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'), + 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'), + primary_tag_name: create(:tag_name, name: 'z_mixed_page'), category: :general) end @@ -264,7 +266,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| - create(:tag, tag_name: create(:tag_name, 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 } @@ -275,8 +277,8 @@ RSpec.describe 'Tags API', type: :request do end it 'falls back to default ordering when order is invalid' do - low = create(:tag, name: 'fallback_low', category: :general) - high = create(:tag, 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) @@ -289,7 +291,7 @@ RSpec.describe 'Tags API', type: :request do it 'normalises invalid page and limit' do %w[norm_a norm_b].each do |name| - create(:tag, tag_name: create(:tag_name, 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,7 +304,7 @@ RSpec.describe 'Tags API', type: :request do it 'returns aliases and parent tags' do parent_tag = create(:tag, - tag_name: create(:tag_name, name: 'index_parent_tag'), + primary_tag_name: create(:tag_name, name: 'index_parent_tag'), category: :meme) TagImplication.create!(tag:, parent_tag:) @@ -348,7 +350,7 @@ RSpec.describe 'Tags API', type: :request do it 'returns aliases and parent tags' do parent_tag = create(:tag, - tag_name: create(:tag_name, name: 'show_parent_tag'), + primary_tag_name: create(:tag_name, name: 'show_parent_tag'), category: :character) TagImplication.create!(tag:, parent_tag:) @@ -395,7 +397,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, @@ -413,7 +415,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 = create(:tag, 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) @@ -452,7 +454,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') }" - create(:tag, category: :general, name:, post_count: 1) + create(:tag, category: :general, primary_name: name, post_count: 1) create(:external_tag, name:, post_count: 1) end @@ -464,8 +466,8 @@ RSpec.describe 'Tags API', type: :request do end it 'excludes external tags but preserves internal alias matches when nico is false' do - internal = create(:tag, category: :general, name: 'switch_internal', post_count: 1) - alias_target = create(:tag, category: :general, name: 'alias_target', post_count: 1) + 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) @@ -473,27 +475,30 @@ RSpec.describe 'Tags API', type: :request do 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 primary, alias, and external names" do literal = "literal#{ wildcard }match" - create(:tag, category: :general, name: literal, post_count: 1) + create(:tag, category: :general, primary_name: literal, post_count: 1) alias_target = create( - :tag, category: :general, name: 'literal_alias_target', post_count: 1) + :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) - create(:tag, 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 @@ -523,7 +528,7 @@ RSpec.describe 'Tags API', type: :request do it 'omits deprecated tags' do deprecated_tag = create(:tag, - name: 'spec_deprecated', + primary_name: 'spec_deprecated', category: :general, deprecated_at: Time.current) deprecated_tag.update_columns(post_count: 1) @@ -619,7 +624,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) @@ -634,7 +639,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 @@ -644,7 +649,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 @@ -654,7 +659,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 @@ -667,7 +672,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 @@ -717,7 +722,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') @@ -745,7 +750,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') @@ -781,8 +786,8 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(tag.name).to eq('spec_tag') - expect(tag.tag_name).to have_attributes( + 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) @@ -791,7 +796,7 @@ RSpec.describe 'Tags API', type: :request do 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') @@ -803,10 +808,10 @@ RSpec.describe 'Tags API', type: :request do 'name' => ['その名前は既に使はれてゐます.'] ) - expect(tag.reload.name).to eq('spec_tag') + 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(tag.tag_name) + expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) end it 'system tag の name は変更できない' do @@ -815,34 +820,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 - create(:tag, name: 'depth_a_root_meme', category: :meme) + create(:tag, primary_name: 'depth_a_root_meme', category: :meme) end let!(:root_material) do - create(:tag, name: 'depth_b_root_material', category: :material) + create(:tag, primary_name: 'depth_b_root_material', category: :material) end let!(:hidden_general_root) do - create(:tag, name: 'depth_hidden_general_root', category: :general) + create(:tag, primary_name: 'depth_hidden_general_root', category: :general) end let!(:child_character) do - create(:tag, name: 'depth_child_character', category: :character) + create(:tag, primary_name: 'depth_child_character', category: :character) end let!(:grandchild_material) do - create(:tag, name: 'depth_grandchild_material', category: :material) + create(:tag, primary_name: 'depth_grandchild_material', category: :material) end let!(:child_general) do - create(:tag, name: 'depth_child_general', category: :general) + create(:tag, primary_name: 'depth_child_general', category: :general) end before do @@ -886,11 +891,11 @@ RSpec.describe 'Tags API', type: :request do it 'passes through deprecated tags when finding children' do deprecated_middle = create(:tag, - name: 'depth_deprecated_middle', + primary_name: 'depth_deprecated_middle', category: :character, deprecated_at: Time.current) visible_descendant = create(:tag, - name: 'depth_visible_descendant', + 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) @@ -904,18 +909,18 @@ RSpec.describe 'Tags API', type: :request do it 'passes through multiple deprecated tags for roots and has_children' do active_child = create(:tag, - name: 'depth_active_child_below_deprecated', + primary_name: 'depth_active_child_below_deprecated', category: :character) deprecated_parent = create(:tag, - name: 'depth_deprecated_parent', + primary_name: 'depth_deprecated_parent', category: :character, deprecated_at: Time.current) deprecated_grandparent = create(:tag, - name: 'depth_deprecated_grandparent', + primary_name: 'depth_deprecated_grandparent', category: :material, deprecated_at: Time.current) active_ancestor = create(:tag, - name: 'depth_active_ancestor', + 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) @@ -942,10 +947,10 @@ RSpec.describe 'Tags API', type: :request do it 'treats an active tag with only deprecated ancestors as a root' do active_child = create(:tag, - name: 'depth_root_below_deprecated', + primary_name: 'depth_root_below_deprecated', category: :character) deprecated_parent = create(:tag, - name: 'depth_root_deprecated_parent', + primary_name: 'depth_root_deprecated_parent', category: :material, deprecated_at: Time.current) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) @@ -962,11 +967,11 @@ RSpec.describe 'Tags API', type: :request do it 'terminates when deprecated implications contain a cycle' do first = create(:tag, - name: 'depth_cycle_first', + primary_name: 'depth_cycle_first', category: :character, deprecated_at: Time.current) second = create(:tag, - name: 'depth_cycle_second', + primary_name: 'depth_cycle_second', category: :material, deprecated_at: Time.current) TagImplication.create!(tag: first, parent_tag: root_material) @@ -994,19 +999,19 @@ RSpec.describe 'Tags API', type: :request do let!(:material_user) { create_member_user! } let!(:root_tag) do - create(:tag, name: 'materials_root', category: :material) + create(:tag, primary_name: 'materials_root', category: :material) end let!(:child_a_tag) do - create(:tag, name: 'materials_child_a', category: :material) + create(:tag, primary_name: 'materials_child_a', category: :material) end let!(:child_b_tag) do - create(:tag, name: 'materials_child_b', category: :character) + create(:tag, primary_name: 'materials_child_b', category: :character) end let!(:grandchild_tag) do - create(:tag, name: 'materials_grandchild', category: :material) + create(:tag, primary_name: 'materials_grandchild', category: :material) end let!(:root_material) do @@ -1028,7 +1033,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) @@ -1119,7 +1124,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 @@ -1131,7 +1136,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 @@ -1151,10 +1156,10 @@ RSpec.describe 'Tags API', type: :request do it 'name, category, aliases, parent tags をまとめて更新できる' do old_parent = create(:tag, - tag_name: create(:tag_name, name: 'put_old_parent'), + primary_tag_name: create(:tag_name, name: 'put_old_parent'), category: :general) kept_parent = create(:tag, - tag_name: create(:tag_name, name: 'put_kept_parent'), + 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) @@ -1171,7 +1176,7 @@ 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!(language_code: 'ja', name: 'put_alias_a')) @@ -1179,17 +1184,16 @@ RSpec.describe 'Tags API', type: :request do 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).to have_attributes( tag_id: tag.id, language_code: 'ja', primary_flg: false) 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 @@ -1290,7 +1294,7 @@ RSpec.describe 'Tags API', type: :request do tag.reload - expect(tag.name).to eq('spec_tag') + 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)) @@ -1309,10 +1313,10 @@ RSpec.describe 'Tags API', type: :request do it 'parent tag の snapshot も作成する' do old_parent = create(:tag, - tag_name: create(:tag_name, name: 'put_snapshot_old_parent'), + primary_tag_name: create(:tag_name, name: 'put_snapshot_old_parent'), category: :general) new_parent = create(:tag, - tag_name: create(:tag_name, name: 'put_snapshot_new_parent'), + primary_tag_name: create(:tag_name, name: 'put_snapshot_new_parent'), category: :general) TagImplication.create!(tag:, parent_tag: old_parent) @@ -1320,7 +1324,7 @@ RSpec.describe 'Tags API', type: :request do name: 'spec_tag', category: 'general', aliases: 'unko', - parent_tags: new_parent.name, + parent_tags: primary_tag_name_for(new_parent, 'ja').name, deprecated: '0', } @@ -1343,13 +1347,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 { @@ -1364,14 +1368,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') @@ -1410,7 +1414,7 @@ RSpec.describe 'Tags API', type: :request 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') @@ -1439,8 +1443,9 @@ 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(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) @@ -1452,7 +1457,7 @@ RSpec.describe 'Tags API', type: :request do it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do old_owner = create(:tag, - tag_name: create(:tag_name, name: 'put_alias_old_owner'), + primary_tag_name: create(:tag_name, name: 'put_alias_old_owner'), category: :general) stolen_alias = create(:tag_name, :alias, name: 'put_stolen_alias', @@ -1493,14 +1498,14 @@ RSpec.describe 'Tags API', type: :request do it '別 tag の alias 名を rename で奪へる' do old_owner = create(:tag, - tag_name: create(:tag_name, name: 'put_alias_collision_owner'), + 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') @@ -1519,7 +1524,7 @@ RSpec.describe 'Tags API', type: :request do old_owner.reload stolen_alias.reload - expect(tag.name).to eq('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')) @@ -1532,12 +1537,12 @@ RSpec.describe 'Tags API', type: :request do 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(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) end it 'parent_tags に指定すると循環する tag は 422 にする' do child = create(:tag, - tag_name: create(:tag_name, name: 'put_cycle_child'), + primary_tag_name: create(:tag_name, name: 'put_cycle_child'), category: :general) TagImplication.create!(tag: child, parent_tag: tag) @@ -1546,7 +1551,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', } @@ -1558,7 +1563,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_spec.rb b/backend/spec/requests/wiki_spec.rb index 7bf7c49..e1d31b1 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -20,7 +20,7 @@ RSpec.describe 'Wiki API', type: :request do end let!(:tag) do create(:tag, - tag_name: tn, + primary_tag_name: tn, category: :general, deprecated_at: Time.zone.local(2026, 6, 1)) end @@ -275,7 +275,7 @@ RSpec.describe 'Wiki API', type: :request do it 'wiki body だけを変更しても tag version は作成しない' do linked_tag_name = create(:tag_name, name: 'wiki_body_only_tag') - linked_tag = create(:tag, tag_name: linked_tag_name, category: :general) + linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) TagVersionRecorder.record!( tag: linked_tag, @@ -535,7 +535,7 @@ RSpec.describe 'Wiki API', type: :request do it 'wiki title を変更すると対応する tag の version を作成する' do linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version') - linked_tag = create(:tag, tag_name: linked_tag_name, category: :general) + linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( @@ -563,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/services/material_sync_importer_spec.rb b/backend/spec/services/material_sync_importer_spec.rb index 7d34dc6..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) { create(:tag, 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 b7811c4..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| create(:tag, 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_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 209def7..5ff805a 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 - create(:tag, name:, category:) + create(:tag, primary_name: name, category:) end before do @@ -16,12 +16,15 @@ RSpec.describe PostCreatePlan do it 'resolves the matching language identity and its parents' do english_name = create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') - english = create(:tag, tag_name: english_name, category: :character) - japanese = create(:tag, name: 'temporary_japanese', category: :general) + english = create(:tag, primary_tag_name: english_name, category: :character) + japanese = create(:tag, primary_name: 'temporary_japanese', category: :general) # The DB permits this identity; validation has its own contract spec. - japanese.tag_name.update_columns(name: 'shared_name') - english_parent = create(:tag, name: 'english_parent', category: :material) - japanese_parent = create(:tag, name: 'japanese_parent', category: :general) + primary_tag_name_for(japanese, '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) + japanese_parent = create(:tag, primary_name: 'japanese_parent', category: :general) TagImplication.create!(tag: english, parent_tag: english_parent) TagImplication.create!(tag: japanese, parent_tag: japanese_parent) @@ -30,15 +33,16 @@ RSpec.describe PostCreatePlan do expect(plan[:direct_tag_specs]).to eq( [{ name: 'shared_name', category: :character }]) expect(plan[:post_tag_specs]).to include( - { name: english_parent.name, category: :material }) - expect(plan[:post_tag_specs].pluck(:name)).not_to include(japanese_parent.name) + { 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(japanese_parent, 'ja').name) end it 'resolves aliases within the requested language' do - japanese = create(:tag, name: 'japanese_canonical') + japanese = create(:tag, primary_name: 'japanese_canonical') english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') - english = create(:tag, tag_name: english_name, category: :character) + english = create(:tag, primary_tag_name: english_name, category: :character) japanese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: japanese) english_alias = create(:tag_name, :alias, name: 'shared_alias', @@ -55,7 +59,7 @@ RSpec.describe PostCreatePlan do plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) expect(plan[:direct_tag_specs]).to eq( - [{ name: english.name, category: :character }]) + [{ name: english_name.name, category: :character }]) end end diff --git a/backend/spec/services/post_import_previewer_spec.rb b/backend/spec/services/post_import_previewer_spec.rb index 5bc7bcc..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 - create(:tag, 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/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb index 9351d75..42c42b7 100644 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -77,7 +77,7 @@ RSpec.describe Wiki::Commit do it 'does not record tag_version on body-only wiki update' do tag_name = create(:tag_name, name: 'commit_integrity_linked_tag') - tag = create(:tag, tag_name:, category: :general) + 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 5d82b34..122ac58 100644 --- a/backend/spec/services/wiki/commit_spec.rb +++ b/backend/spec/services/wiki/commit_spec.rb @@ -81,7 +81,7 @@ RSpec.describe Wiki::Commit do it 'does not record tag version when corresponding tag has no versions' do tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') - tag = create(:tag, tag_name:, category: :general) + tag = create(:tag, primary_tag_name: tag_name, category: :general) page = described_class.create_content!( @@ -108,7 +108,7 @@ RSpec.describe Wiki::Commit do it 'does not record tag version when corresponding tag has no versions' do tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') - tag = create(:tag, tag_name:, category: :general) + tag = create(:tag, primary_tag_name: tag_name, category: :general) page = described_class.create_content!( 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 e62a8f3..6ae8afa 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -64,7 +64,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 +435,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 +449,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 +568,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 +584,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 1934f66..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 = create(:tag, name: 't1') - t2 = create(:tag, name: 't2') - t3 = create(:tag, name: 't3') - deprecated_tag = create(:tag, 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 fe293a6..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 = create(:tag, name: 't1') - t2 = create(:tag, name: 't2') - t3 = create(:tag, name: 't3') - deprecated_tag = create(:tag, 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") -- 2.53.0 From 56205b7052e36ca686061163d9c191ecca45cefd Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 27 Sep 2026 17:13:02 +0900 Subject: [PATCH 09/21] #55 --- backend/app/controllers/tags_controller.rb | 134 ++++++++++++++------- backend/app/models/tag.rb | 30 ++--- backend/app/models/tag_name.rb | 7 ++ 3 files changed, 104 insertions(+), 67 deletions(-) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index d8cbcd4..b1057c2 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 @@ -205,6 +213,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,28 +223,28 @@ class TagsController < ApplicationController alias_rows = TagName + .where(language_code: locale.language_code, primary_code: 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 + tag_ids = [] + alias_rows.each do |tag_id, alias_name| + tag_ids << tag_id + matched_alias_by_tag_name_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 @@ -265,11 +275,14 @@ class TagsController < ApplicationController end def show + locale = resolve_locale! + tag = Tag - .joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(id: params[:id]) + .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) if tag external_tag = ExternalTag.find_by(id: params[:id]) @@ -282,11 +295,13 @@ 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: }) + .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) if tag platform, external_name = name.split(':', 2) @@ -299,9 +314,13 @@ 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), @@ -312,9 +331,13 @@ class TagsController < ApplicationController 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), @@ -325,14 +348,19 @@ class TagsController < ApplicationController 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]) + tag = + Tag + .joins(:tag_names) + .includes(:tag_names, tag_names: :wiki_page) + .find_by(id: params[:id], + tag_names: { language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag rows = normalise_deerjikist_rows(tag) return if performed? + locale = resolve_locale! + ApplicationRecord.transaction do tag.lock! @@ -346,7 +374,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 +395,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 @@ -381,11 +409,16 @@ class TagsController < ApplicationController 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) @@ -506,6 +539,8 @@ class TagsController < ApplicationController end def build_with_depth_graph material_filter + locale = resolve_locale! + children_by_parent_id = Hash.new { |h, k| h[k] = [] } parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } @@ -520,10 +555,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: locale.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 @@ -857,15 +895,19 @@ 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 + locale = resolve_locale! + + 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/models/tag.rb b/backend/app/models/tag.rb index a8cdfd6..9add0ca 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -51,12 +51,9 @@ class Tag < ApplicationRecord has_many :tag_versions has_many :tag_names - delegate :wiki_page, to: :tag_name attribute :version_no, :integer, default: 1 - validates :tag_name, presence: true - enum :category, deerjikist: 'deerjikist', meme: 'meme', character: 'character', @@ -66,8 +63,6 @@ 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 CATEGORY_PREFIXES = { @@ -84,6 +79,7 @@ class Tag < ApplicationRecord def tag_name(language_code) = tag_names.find_by(language_code:, primary_flg: true) def name(language_code) = tag_name(language_code)&.name + def wiki_page = WikiPage.find_by(tag_name_id: id) def deprecated? = deprecated_at? @@ -234,7 +230,7 @@ class Tag < ApplicationRecord tag = tn.tag return tag if tag - tag = Tag.create!(tag_name: tn, category:) + tag = Tag.create!(tag_name_id: tn.id, category:) tn.update!(tag:) tag @@ -295,28 +291,20 @@ class Tag < ApplicationRecord target_tag.reload end - def snapshot_aliases = tag_name.aliases.order(:name).pluck(:name) + def snapshot_aliases(language_code) = tag_name(language_code).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 private - def tag_name_mustnt_start_with_nico - if name.to_s.downcase.start_with?('nico:') - errors.add :name, 'タグの命名規則に反してゐます.' - end - end - - def tag_name_must_be_canonical - if tag_name&.canonical_id? - errors.add :tag_name, 'tag_names へは実体を示す必要があります.' - end - end - def category_must_be_deerjikist_with_deerjikists if !(deerjikist?) && deerjikists.exists? errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 046cc27..0dc2ebc 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -10,6 +10,7 @@ class TagName < ApplicationRecord validate :alias_name_must_not_have_prefix validate :alias_must_not_have_wiki_page validate :name_must_be_sanitised + validate :name_mustnt_start_with_nico def primary? = primary_flg @@ -58,4 +59,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 -- 2.53.0 From ab1b0e9def337a83b1f76917f2282208514612e0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 27 Sep 2026 17:16:08 +0900 Subject: [PATCH 10/21] #55 --- backend/app/models/locale.rb | 2 +- backend/app/models/tag_name.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index 51f4954..afb7e17 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -13,7 +13,7 @@ class Locale < ApplicationRecord Tag.where.not(id: tag_ids).find_each do TagName.create!(tag_id: _1.id, language_code:, - name: TagName.generate_name(self, _1, _1.name), + name: TagName.generate_name!(self, _1, _1.name(language_code)), script_code:, primary_flg: true, # TODO: 公証実装したら書く. diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 0dc2ebc..80a51cb 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -34,7 +34,7 @@ class TagName < ApplicationRecord }.uniq end - def self.generate_name locale, tag, name + def self.generate_name! locale, tag, name # TODO: 言語ごとの自動命名ロジック完成したら書く. "Tag_##{ tag.id }" -- 2.53.0 From 749d49327e799e7818d3097863fc4d9131a877d2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 27 Sep 2026 23:55:19 +0900 Subject: [PATCH 11/21] #55 --- .../app/controllers/deerjikists_controller.rb | 2 +- .../controllers/gekanator_posts_controller.rb | 4 +- .../gekanator_questions_controller.rb | 4 +- .../app/controllers/materials_controller.rb | 30 ++-- .../app/controllers/nico_tags_controller.rb | 10 +- backend/app/controllers/posts_controller.rb | 40 +++--- .../controllers/tag_versions_controller.rb | 2 +- backend/app/controllers/tags_controller.rb | 134 +++++++++++------- .../theatre_skip_events_controller.rb | 4 +- backend/app/models/external_tag.rb | 8 +- backend/app/models/language.rb | 2 +- backend/app/models/locale.rb | 2 +- backend/app/models/post.rb | 18 +-- backend/app/models/tag.rb | 10 +- backend/app/models/tag_name.rb | 12 +- .../app/models/tag_name_sanitisation_rule.rb | 1 + .../app/representations/deerjikist_repr.rb | 3 +- backend/app/representations/material_repr.rb | 5 +- backend/app/representations/post_repr.rb | 2 +- backend/app/representations/tag_repr.rb | 15 +- .../app/services/material_version_recorder.rb | 2 +- backend/app/services/post_bulk_creator.rb | 2 +- backend/app/services/post_create_plan.rb | 41 +++--- backend/app/services/post_create_preflight.rb | 4 +- backend/app/services/post_creator.rb | 21 +-- backend/app/services/post_import_previewer.rb | 29 ++-- backend/app/services/post_metadata_fetcher.rb | 6 +- backend/app/services/tag_version_recorder.rb | 11 +- backend/app/services/tag_versioning.rb | 16 +-- backend/app/services/theatre_post_selector.rb | 6 +- 30 files changed, 258 insertions(+), 188 deletions(-) diff --git a/backend/app/controllers/deerjikists_controller.rb b/backend/app/controllers/deerjikists_controller.rb index 9302370..4d25cb6 100644 --- a/backend/app/controllers/deerjikists_controller.rb +++ b/backend/app/controllers/deerjikists_controller.rb @@ -7,7 +7,7 @@ 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) 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 837cb84..5a04654 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -20,7 +20,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) @@ -63,7 +63,7 @@ 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:) end @@ -96,7 +96,8 @@ class MaterialsController < ApplicationController 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) @@ -148,7 +149,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) @@ -254,7 +256,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 @@ -270,9 +277,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 @@ -324,8 +333,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 4f8275c..03f17f2 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -24,7 +24,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 +34,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) @@ -95,7 +96,10 @@ class NicoTagsController < ApplicationController 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! diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 6b1c633..d88271b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -53,7 +53,7 @@ class PostsController < ApplicationController .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 @@ -110,7 +110,7 @@ class PostsController < ApplicationController 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 @@ -195,7 +195,7 @@ class PostsController < ApplicationController 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 @@ -341,7 +341,7 @@ 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(locale:, title:, @@ -418,11 +418,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 @@ -432,8 +432,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 @@ -441,11 +441,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 = @@ -688,24 +688,25 @@ class PostsController < ApplicationController .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? @@ -732,7 +733,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 @@ -868,7 +869,8 @@ class PostsController < ApplicationController 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..b861cfe 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -47,7 +47,7 @@ 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) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index b1057c2..643a548 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -176,7 +176,7 @@ class TagsController < ApplicationController Tag .joins(:tag_names) .includes(:tag_names, :materials, tag_names: :wiki_page) - .where(tag_names: { language_code: locale.language.code, primary_flg: true }, + .where(tag_names: { language_code: locale.language_code, primary_flg: true }, id: tag_ids) .index_by(&:id) external_tags_by_id = @@ -186,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 @@ -223,15 +223,15 @@ class TagsController < ApplicationController alias_rows = TagName - .where(language_code: locale.language_code, primary_code: false) + .where(language_code: locale.language_code, primary_flg: false) .where('name LIKE ?', prefix) .pluck(:tag_id, :name) - matched_alias_by_tag_name_id = { } + matched_alias_by_tag_id = { } tag_ids = [] alias_rows.each do |tag_id, alias_name| tag_ids << tag_id - matched_alias_by_tag_name_id[tag_id] ||= alias_name + matched_alias_by_tag_id[tag_id] ||= alias_name end base = @@ -251,7 +251,8 @@ class TagsController < ApplicationController .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 @@ -267,8 +268,8 @@ 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 @@ -283,7 +284,7 @@ class TagsController < ApplicationController .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) if tag + 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 @@ -302,7 +303,7 @@ class TagsController < ApplicationController .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) if tag + 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 @@ -323,7 +324,7 @@ class TagsController < ApplicationController tag_names: { language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag - render json: { tag: TagRepr.base(tag), + render json: { tag: TagRepr.base(tag, language_code: locale.language_code), deerjikists: DeerjikistRepr.many(tag.deerjikists) } end @@ -340,7 +341,7 @@ class TagsController < ApplicationController .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), + render json: { tag: TagRepr.base(tag, language_code: locale.language_code), deerjikists: DeerjikistRepr.many(tag.deerjikists) } end @@ -348,6 +349,8 @@ class TagsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! + tag = Tag .joins(:tag_names) @@ -359,8 +362,6 @@ class TagsController < ApplicationController rows = normalise_deerjikist_rows(tag) return if performed? - locale = resolve_locale! - ApplicationRecord.transaction do tag.lock! @@ -423,7 +424,7 @@ class TagsController < ApplicationController graph = build_with_depth_graph(material_filter) - render json: build_tag_children(tag, graph:) + render json: build_tag_children(tag, graph:, language_code: locale.language_code) end def update_all @@ -452,11 +453,12 @@ 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:) @@ -468,7 +470,7 @@ class TagsController < ApplicationController alias_names << old_name if name_changed alias_names.delete(name) - update_aliases!(tag, alias_names) + update_aliases!(locale, tag, alias_names) update_parent_tags!(locale, tag, parent_names) tag.reload @@ -477,11 +479,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 def update @@ -504,13 +507,14 @@ 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.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) @@ -522,11 +526,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 @@ -693,29 +698,30 @@ 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)) 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!( @@ -725,9 +731,13 @@ class TagsController < ApplicationController end def validate_tag_rename locale, tag, name - return true if name.blank? || name == 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 @@ -746,7 +756,16 @@ class TagsController < ApplicationController target_tag_name = TagName.find_by(language_code: locale.language_code, name:) unless target_tag_name - current_tag_name.update!(name:) + if current_tag_name + current_tag_name.update!(name:) + else + current_tag_name = TagName.create!( + tag:, + language_code: locale.language_code, + script_code: locale.script_code, + name:, + primary_flg: true) + end return end @@ -754,68 +773,79 @@ 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_id: nil, primary_flg: true) + current_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) + promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true) TagName.where(tag:, - language_code: current_tag_name.language.language_code, + 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_id: promoted_tag_name, primary_flg: false) + alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) end current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) 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_id: promoted_tag_name, primary_flg: false) + 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 = tag.tag_name(locale.language_code) + 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) + 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_id: 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_id: tag.tag_name.id) + 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, 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 @@ -899,8 +929,6 @@ class TagsController < ApplicationController conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id } return if conflicts.empty? - locale = resolve_locale! - tag_names_by_id = Tag .joins(:tag_names) 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/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 index cd37ec3..1572f28 100644 --- a/backend/app/models/language.rb +++ b/backend/app/models/language.rb @@ -1,3 +1,3 @@ class Language < ApplicationRecord - has_many :languages, class_name: 'Locale', foreign_key: :language_code + has_many :locales, foreign_key: :language_code, primary_key: :code end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index afb7e17..a2081f6 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -13,7 +13,7 @@ class Locale < ApplicationRecord Tag.where.not(id: tag_ids).find_each do TagName.create!(tag_id: _1.id, language_code:, - name: TagName.generate_name!(self, _1, _1.name(language_code)), + name: TagName.generate_name(self, _1, _1.name('ja')), script_code:, primary_flg: true, # TODO: 公証実装したら書く. 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/tag.rb b/backend/app/models/tag.rb index 9add0ca..e449781 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -79,7 +79,7 @@ class Tag < ApplicationRecord def tag_name(language_code) = tag_names.find_by(language_code:, primary_flg: true) def name(language_code) = tag_name(language_code)&.name - def wiki_page = WikiPage.find_by(tag_name_id: id) + def wiki_page = tag_name('ja')&.wiki_page def deprecated? = deprecated_at? @@ -132,7 +132,7 @@ class Tag < ApplicationRecord find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag| if deny_deprecated && tag.deprecated? - raise DeprecatedTagNormalisationError, [tag.name] + raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)] end tag.update!(category: cat) if cat && tag.category != cat @@ -262,7 +262,7 @@ class Tag < ApplicationRecord end end - source_tag_name = source_tag.tag_name + source_tag_name = source_tag.tag_name('ja') if source_tag_name.wiki_page.present? raise ActiveRecord::RecordInvalid.new(source_tag_name) @@ -291,7 +291,9 @@ class Tag < ApplicationRecord target_tag.reload end - def snapshot_aliases(language_code) = tag_name(language_code).aliases.order(:name).pluck(:name) + def snapshot_aliases(language_code) + tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) + end def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 80a51cb..5d47460 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -14,9 +14,6 @@ class TagName < ApplicationRecord def primary? = primary_flg - def canonical = TagName.find_by(language_code:, tag_id:, primary_flg: true) - def aliases = TagName.where(language_code:, tag_id:, primary_flg: false) - def self.canonicalise locale, names names = Array(names).map { |n| n.to_s.strip }.reject(&:blank?) return [] if names.blank? @@ -24,17 +21,18 @@ class TagName < ApplicationRecord tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) names.map { |name| - if !(tns[name]) || tns[name].primary? + tag_name = tns[name] + if !tag_name || tag_name.primary? name else TagName.find_by(language_code: locale.language_code, - tag_id: tns[name].tag_id, - primary_flg: true).name + tag_id: tag_name.tag_id, + primary_flg: true)&.name || name end }.uniq end - def self.generate_name! locale, tag, name + def self.generate_name locale, tag, name # TODO: 言語ごとの自動命名ロジック完成したら書く. "Tag_##{ tag.id }" diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index 1086bbb..4731d4f 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -36,6 +36,7 @@ class TagNameSanitisationRule < ApplicationRecord 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 diff --git a/backend/app/representations/deerjikist_repr.rb b/backend/app/representations/deerjikist_repr.rb index fbb73c9..552e350 100644 --- a/backend/app/representations/deerjikist_repr.rb +++ b/backend/app/representations/deerjikist_repr.rb @@ -7,7 +7,8 @@ module DeerjikistRepr module_function def base deerjikist - deerjikist.as_json(BASE) + deerjikist.as_json(BASE).merge( + tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code: 'ja')) end def many deerjikists diff --git a/backend/app/representations/material_repr.rb b/backend/app/representations/material_repr.rb index 30d21f9..4893dbd 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -14,6 +14,7 @@ module MaterialRepr def base material, host: material.as_json(BASE).merge( + tag: material.tag && TagRepr.base(material.tag, language_code: 'ja'), file: if material.file.attached? Rails.application.routes.url_helpers.rails_storage_proxy_url( material.file, host:) @@ -80,7 +81,7 @@ module MaterialRepr end def thumbnail_fallback_text material - material.tag&.name || material.created_at&.strftime('%Y-%m-%d') + material.tag&.name('ja') || material.created_at&.strftime('%Y-%m-%d') end def thumbnail_fallback_kind material @@ -102,7 +103,7 @@ module MaterialRepr return nil unless tag { id: tag.id, - name: tag.name, + name: tag.name('ja'), 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..fce7ccf 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -91,7 +91,7 @@ module PostRepr post .post_tags .reject { _1.tag.deprecated? } - .sort_by { _1.tag.name } + .sort_by { _1.tag.name('ja') } .map { |post_tag| TagRepr.inline(post_tag.tag).merge( 'children' => [], diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index 3958953..d54e743 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -3,18 +3,19 @@ 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: tag.name(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: tag.name(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:) } 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 e525c57..38ec06e 100644 --- a/backend/app/services/post_bulk_creator.rb +++ b/backend/app/services/post_bulk_creator.rb @@ -66,7 +66,7 @@ class PostBulkCreator 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', diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index e1279a1..58fbaf2 100644 --- a/backend/app/services/post_create_plan.rb +++ b/backend/app/services/post_create_plan.rb @@ -12,8 +12,8 @@ class PostCreatePlan 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) + 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) @@ -49,7 +49,9 @@ class PostCreatePlan tag_names.each do |raw_name| tag_name, category, sections = parse_raw_tag_name(locale, raw_name) existing_tag = existing_tags_by_name(locale)[tag_name] - raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? + if existing_tag&.deprecated? + raise Tag::DeprecatedTagNormalisationError, [existing_tag.name(locale.language_code)] + end direct_tag_specs << { name: tag_name, @@ -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 @@ -139,7 +142,7 @@ class PostCreatePlan 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) @@ -161,7 +164,9 @@ class PostCreatePlan def existing_tags_by_name locale @existing_tags_by_name ||= begin names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq - Tag.joins(:tag_name).where(tag_names: { name: names }).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 end @@ -171,9 +176,11 @@ class PostCreatePlan return if missing_names.empty? existing_tags_by_name(locale).merge!( - Tag.joins(:tag_name) - .where(tag_names: { name: missing_names }) - .index_by(&:name)) + 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 locale, raw_name @@ -266,8 +273,8 @@ class PostCreatePlan tag_name = TagName.find_by(language_code: locale.language_code, name:) return name if !(tag_name) || tag_name.primary? - TagName.find_by!(language_code: locale.language_code, - tag_id: tag_name.tag_id, - primary_flg: true).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..24eb21a 100644 --- a/backend/app/services/post_create_preflight.rb +++ b/backend/app/services/post_create_preflight.rb @@ -15,7 +15,7 @@ class PostCreatePreflight @host = host end - def run + def run locale preview = PostImportPreviewer.new.preview_rows( rows: [preview_row], fetch_metadata: false).first @@ -53,7 +53,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 f328068..fe3e6a1 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -24,7 +24,10 @@ class PostCreator 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) + 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) @@ -70,7 +73,8 @@ class PostCreator 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 @@ -81,15 +85,16 @@ class PostCreator def build_materialised_plan locale plan = PostCreatePlan.new(attributes: @attributes).build!(locale) - snapshot_tags = materialise_tags(plan[:snapshot_tag_specs] || []) - post_tags = materialise_tags(plan[:post_tag_specs] || []) + 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 @@ -106,10 +111,10 @@ class PostCreator 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..6ce25e2 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 locale, rows:, fetch_metadata: true, metadata_cache: { } prepared_rows = rows.map { prepare_row(_1) } url_counts = prepared_rows.filter_map { _1[:normal_url] }.tally existing_posts = @@ -36,7 +36,7 @@ class PostImportPreviewer existing_posts, url_counts) prepared_rows.map { |row| - preview_row(row, + preview_row(locale, row, fetch_metadata:, metadata_cache:, existing_posts:, @@ -59,7 +59,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 +124,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) @@ -362,11 +363,9 @@ class PostImportPreviewer }.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: 'ja', 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 +436,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,7 +458,9 @@ 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) errors[:tags] = ["廃止済みタグがあります: #{ deprecated.join(' ') }"] if deprecated.present? 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..c7c8bd4 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -1,10 +1,11 @@ class TagVersionRecorder < VersionRecorder - def self.record! tag:, event_type:, created_by_user: - new(tag:, event_type:, created_by_user:).record! + def self.record! tag:, event_type:, created_by_user:, language_code: 'ja' + new(tag:, event_type:, created_by_user:, language_code:).record! end - def initialize tag:, event_type:, created_by_user: + def initialize tag:, event_type:, created_by_user:, language_code: super(record: tag, event_type:, created_by_user:) + @language_code = language_code end private @@ -14,10 +15,10 @@ class TagVersionRecorder < VersionRecorder def record_key = :tag def snapshot_attributes - { name: @record.name, + { name: @record.name(@language_code), category: @record.category, deprecated_at: @record.deprecated_at, - aliases: @record.snapshot_aliases.join(' '), + aliases: @record.snapshot_aliases(@language_code).join(' '), parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } 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 -- 2.53.0 From 28e638e360695aa34d8fb5a410ee4eebd66543df Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 28 Sep 2026 00:30:03 +0900 Subject: [PATCH 12/21] #55 --- .../app/controllers/deerjikists_controller.rb | 7 ++- .../app/controllers/materials_controller.rb | 18 +++++-- .../app/controllers/nico_tags_controller.rb | 10 +++- backend/app/controllers/posts_controller.rb | 24 ++++++---- .../controllers/tag_versions_controller.rb | 8 ++-- backend/app/controllers/tags_controller.rb | 27 +++++++---- backend/app/models/tag_version.rb | 1 + .../app/representations/deerjikist_repr.rb | 8 ++-- backend/app/representations/material_repr.rb | 28 +++++------ backend/app/representations/post_repr.rb | 16 +++---- backend/app/services/post_create_preflight.rb | 1 + backend/app/services/post_import_previewer.rb | 9 ++-- backend/app/services/tag_version_recorder.rb | 25 +++++++--- ...0260922040000_add_names_to_tag_versions.rb | 36 ++++++++++++++ backend/db/schema.rb | 3 +- backend/spec/models/version_record_spec.rb | 4 ++ backend/spec/requests/tag_versions_spec.rb | 5 ++ .../services/tag_version_recorder_spec.rb | 47 +++++++++++++++++++ 18 files changed, 209 insertions(+), 68 deletions(-) create mode 100644 backend/db/migrate/20260922040000_add_names_to_tag_versions.rb create mode 100644 backend/spec/services/tag_version_recorder_spec.rb diff --git a/backend/app/controllers/deerjikists_controller.rb b/backend/app/controllers/deerjikists_controller.rb index 4d25cb6..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? @@ -10,7 +12,7 @@ class DeerjikistsController < ApplicationController .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/materials_controller.rb b/backend/app/controllers/materials_controller.rb index 5a04654..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 @@ -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) @@ -65,7 +72,8 @@ class MaterialsController < ApplicationController 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 @@ -109,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 @@ -162,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 diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 03f17f2..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 @@ -68,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 @@ -109,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 d88271b..99da557 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -92,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) @@ -116,7 +117,7 @@ class PostsController < ApplicationController .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 @@ -191,6 +192,8 @@ class PostsController < ApplicationController end def show + locale = resolve_locale! + post = Post .includes(:uploaded_user, :parents, :children, @@ -209,8 +212,9 @@ 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 @@ -251,7 +255,7 @@ class PostsController < ApplicationController 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 @@ -379,8 +383,8 @@ class PostsController < ApplicationController 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: ['ニコニコ・タグは直接指定できません.'] } @@ -487,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) @@ -517,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) @@ -529,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, []) } diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index b861cfe..6709381 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 @@ -27,12 +28,12 @@ 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 @@ -54,13 +55,14 @@ class TagVersionsController < ApplicationController 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, + names: row.names, name: { current: row.name, prev: row.attributes['prev_name'] }, category: { current: row.category, prev: row.attributes['prev_category'] }, deprecated_at: { current: row.deprecated_at&.iso8601, diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 643a548..53f738e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -198,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 @@ -325,7 +326,9 @@ class TagsController < ApplicationController return head :not_found unless tag render json: { tag: TagRepr.base(tag, language_code: locale.language_code), - deerjikists: DeerjikistRepr.many(tag.deerjikists) } + deerjikists: DeerjikistRepr.many( + tag.deerjikists, + language_code: locale.language_code) } end def deerjikists_by_name @@ -342,7 +345,9 @@ class TagsController < ApplicationController return head :not_found unless tag render json: { tag: TagRepr.base(tag, language_code: locale.language_code), - deerjikists: DeerjikistRepr.many(tag.deerjikists) } + deerjikists: DeerjikistRepr.many( + tag.deerjikists, + language_code: locale.language_code) } end def update_deerjikists @@ -404,7 +409,8 @@ 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 @@ -422,7 +428,7 @@ class TagsController < ApplicationController .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:, language_code: locale.language_code) end @@ -543,9 +549,7 @@ class TagsController < ApplicationController value end - def build_with_depth_graph material_filter - locale = resolve_locale! - + 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] = [] } @@ -564,7 +568,7 @@ class TagsController < ApplicationController Tag .joins(:tag_names) .where(id: tag_ids, - tag_names: { language_code: locale.language_code, primary_flg: true }) + 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?, @@ -712,7 +716,10 @@ class TagsController < ApplicationController 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:, language_code: 'ja', 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 552e350..dbe8fb8 100644 --- a/backend/app/representations/deerjikist_repr.rb +++ b/backend/app/representations/deerjikist_repr.rb @@ -6,12 +6,12 @@ module DeerjikistRepr module_function - def base deerjikist + def base deerjikist, language_code: 'ja' deerjikist.as_json(BASE).merge( - tag: deerjikist.tag && TagRepr.base(deerjikist.tag, language_code: 'ja')) + 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 4893dbd..0482569 100644 --- a/backend/app/representations/material_repr.rb +++ b/backend/app/representations/material_repr.rb @@ -12,15 +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: 'ja'), + 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, @@ -28,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, @@ -41,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, @@ -54,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 @@ -80,8 +80,8 @@ module MaterialRepr material.thumbnail, host:) end - def thumbnail_fallback_text material - material.tag&.name('ja') || 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 @@ -99,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('ja'), + 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 fce7ccf..abd0dd2 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('ja') } + .sort_by { _1.tag.name(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/services/post_create_preflight.rb b/backend/app/services/post_create_preflight.rb index 24eb21a..c27fc3c 100644 --- a/backend/app/services/post_create_preflight.rb +++ b/backend/app/services/post_create_preflight.rb @@ -18,6 +18,7 @@ class PostCreatePreflight def run locale preview = PostImportPreviewer.new.preview_rows( rows: [preview_row], + locale:, fetch_metadata: false).first if preview[:existing_post_id].present? return { diff --git a/backend/app/services/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index 6ce25e2..a81a6b6 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 locale, 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,6 +30,7 @@ class PostImportPreviewer url_counts) known_tags = preload_known_tags( + locale, prepared_rows, fetch_metadata, metadata_cache, @@ -347,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) @@ -359,11 +360,11 @@ 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? - TagName.where(language_code: 'ja', primary_flg: true, name: names) + 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 diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index c7c8bd4..d5debc8 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -1,11 +1,10 @@ class TagVersionRecorder < VersionRecorder - def self.record! tag:, event_type:, created_by_user:, language_code: 'ja' - new(tag:, event_type:, created_by_user:, language_code:).record! + def self.record! tag:, event_type:, created_by_user:, **_options + new(tag:, event_type:, created_by_user:).record! end - def initialize tag:, event_type:, created_by_user:, language_code: + def initialize tag:, event_type:, created_by_user: super(record: tag, event_type:, created_by_user:) - @language_code = language_code end private @@ -15,10 +14,24 @@ class TagVersionRecorder < VersionRecorder def record_key = :tag def snapshot_attributes - { name: @record.name(@language_code), + names = snapshot_names + japanese_names = names.select { _1[:language_code] == 'ja' } + compatibility_names = japanese_names.presence || names + + { name: compatibility_names.find { _1[:primary_flg] }[:name], category: @record.category, deprecated_at: @record.deprecated_at, - aliases: @record.snapshot_aliases(@language_code).join(' '), + aliases: compatibility_names.reject { _1[:primary_flg] }.map { _1[:name] }.join(' '), + names:, parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } end + + def snapshot_names + @record.tag_names.order(:language_code, primary_flg: :desc).order(:name).map { |tag_name| + { language_code: tag_name.language_code, + name: tag_name.name, + script_code: tag_name.script_code, + primary_flg: tag_name.primary_flg } + } + 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..4a84eb2 --- /dev/null +++ b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb @@ -0,0 +1,36 @@ +class AddNamesToTagVersions < ActiveRecord::Migration[8.0] + class TagVersion < ActiveRecord::Base + self.table_name = 'tag_versions' + end + + def up + add_column :tag_versions, :names, :json + TagVersion.reset_column_information + + say_with_time 'Backfilling multilingual tag version names' do + TagVersion.find_each do |version| + names = [{ + language_code: 'ja', + name: version.name, + script_code: 'Jpan', + primary_flg: true }] + names.concat(version.aliases.to_s.split.filter_map { |name| + next if name.blank? + + { + language_code: 'ja', + name:, + script_code: 'Jpan', + primary_flg: false } + }) + version.update_columns(names: names) + end + end + + change_column_null :tag_versions, :names, false + end + + def down + remove_column :tag_versions, :names + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index c6b886b..8862e73 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_22_030000) 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 @@ -480,6 +480,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_030000) 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 } diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index 32a28ed..b5cfffc 100644 --- a/backend/spec/models/version_record_spec.rb +++ b/backend/spec/models/version_record_spec.rb @@ -12,6 +12,8 @@ RSpec.describe VersionRecord, type: :model do name: primary_tag_name_for(tag, 'ja').name, category: tag.category, aliases: '', + names: [{ language_code: 'ja', name: primary_tag_name_for(tag, 'ja').name, + script_code: 'Jpan', primary_flg: true }], parent_tag_ids: '', created_at: Time.current, created_by_user: nil @@ -30,6 +32,8 @@ RSpec.describe VersionRecord, type: :model do name: primary_tag_name_for(tag, 'ja').name, category: tag.category, aliases: '', + names: [{ language_code: 'ja', name: primary_tag_name_for(tag, 'ja').name, + script_code: 'Jpan', primary_flg: true }], parent_tag_ids: '', created_at: Time.current, created_by_user: nil diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 4aa441b..3a6383a 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -36,6 +36,11 @@ RSpec.describe 'TagVersions API', type: :request do category: category, deprecated_at: deprecated_at, aliases: Array(aliases).join(' '), + names: [{ language_code: 'ja', name: name, script_code: 'Jpan', + primary_flg: true }] + Array(aliases).map { |alias_name| + { language_code: 'ja', name: alias_name, script_code: 'Jpan', + primary_flg: false } + }, parent_tag_ids: Array(parent_tags).map(&:id).join(' '), created_by_user: created_by_user, created_at: created_at) 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..9bec5d7 --- /dev/null +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +RSpec.describe TagVersionRecorder do + let(:user) { create_member_user! } + let(:tag) { create(:tag, primary_name: '日本語名') } + + before do + Language.insert_all!([{ code: 'en', name: 'English', created_at: Time.current }]) + Script.insert_all!([{ code: 'Latn', name: 'Latin', created_at: Time.current }]) + Locale.insert_all!([{ code: 'en', language_code: 'en', script_code: 'Latn', + name: 'English', created_at: Time.current }]) + TagName.create!(tag:, language_code: 'ja', script_code: 'Jpan', name: '別名', + primary_flg: false, + canonical_id: tag.tag_name_id) + TagName.create!(tag:, language_code: 'en', script_code: 'Latn', name: 'English name', + primary_flg: true) + TagName.create!(tag:, language_code: 'en', script_code: 'Latn', name: 'English alias', + primary_flg: false) + end + + it 'snapshots every language primary name and alias deterministically' do + version = described_class.record!(tag:, event_type: :create, created_by_user: user) + + expect(version.names).to eq([ + { 'language_code' => 'en', 'name' => 'English name', 'script_code' => 'Latn', + 'primary_flg' => true }, + { 'language_code' => 'en', 'name' => 'English alias', 'script_code' => 'Latn', + 'primary_flg' => false }, + { 'language_code' => 'ja', 'name' => '日本語名', 'script_code' => 'Jpan', + 'primary_flg' => true }, + { 'language_code' => 'ja', 'name' => '別名', 'script_code' => 'Jpan', + 'primary_flg' => false } + ]) + end + + it 'creates a version for an English-only name change and preserves prior names' do + original = described_class.record!(tag:, event_type: :create, created_by_user: user) + TagName.find_by!(tag:, language_code: 'en', primary_flg: true).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).to include( + hash_including('language_code' => 'en', 'name' => 'English name')) + expect(updated.names).to include(hash_including('language_code' => 'en', 'name' => 'Renamed')) + end +end -- 2.53.0 From 56b656109d4c2aba78f3959e7497293044561227 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 28 Sep 2026 01:15:00 +0900 Subject: [PATCH 13/21] #55 --- backend/app/controllers/posts_controller.rb | 4 +++- .../controllers/tag_versions_controller.rb | 19 +++++++++++---- backend/app/controllers/tags_controller.rb | 11 ++++++--- .../app/controllers/wiki_pages_controller.rb | 6 ++++- backend/app/models/tag.rb | 24 ++++++++++++++++++- backend/app/representations/post_repr.rb | 2 +- backend/app/representations/tag_repr.rb | 8 +++++-- backend/app/services/post_import_previewer.rb | 2 +- backend/app/services/tag_version_recorder.rb | 15 ++++++------ ...0260922040000_add_names_to_tag_versions.rb | 6 +++-- ...2050000_add_auto_generated_to_tag_names.rb | 19 +++++++++++++++ backend/db/schema.rb | 3 ++- backend/spec/factories/tag_names.rb | 1 + .../services/tag_version_recorder_spec.rb | 17 +++++++++---- 14 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 99da557..3843bb7 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -689,6 +689,7 @@ class PostsController < ApplicationController version.tags_json .select { _1.key?('tag_id') } .map { Post.tag_snapshot_literal(_1) } + .compact .sort end @@ -716,7 +717,7 @@ class PostsController < ApplicationController next name if sections.empty? "#{ name }#{ sections.map { Post.section_literal(_1) }.join }" - end + end.compact.sort end def post_incoming_snapshot locale:, title:, original_created_from:, original_created_before:, @@ -725,6 +726,7 @@ class PostsController < ApplicationController Tag.normalise_tags!(locale, tag_names, with_tagme: false, + with_no_deerjikist: false, deny_deprecated: true, with_sections: true) => { tags:, sections: } diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 6709381..2aab1db 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -19,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 @@ -38,8 +39,10 @@ class TagVersionsController < ApplicationController 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.reject { _1['primary_flg'] }.map { _1['name'] } + prev_aliases = previous_names.reject { _1['primary_flg'] }.map { _1['name'] } cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) @@ -63,7 +66,8 @@ class TagVersionsController < ApplicationController version_no: row.version_no, event_type: row.event_type, names: row.names, - name: { current: row.name, prev: row.attributes['prev_name'] }, + name: { current: current_names.find { _1['primary_flg'] }&.fetch('name'), + prev: previous_names.find { _1['primary_flg'] }&.fetch('name') }, category: { current: row.category, prev: row.attributes['prev_category'] }, deprecated_at: { current: row.deprecated_at&.iso8601, prev: row.attributes['prev_deprecated_at']&.iso8601 }, @@ -93,5 +97,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) : Array(names) + values.select { _1['language_code'] == language_code } + rescue JSON::ParserError + [] + end + def split_parent_tag_ids(values) = split_values(values).map(&:to_i) end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 53f738e..58df873 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -473,7 +473,7 @@ class TagsController < ApplicationController end 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!(locale, tag, alias_names) @@ -772,6 +772,7 @@ class TagsController < ApplicationController script_code: locale.script_code, name:, primary_flg: true) + tag.association(:tag_names).reset end return end @@ -786,6 +787,9 @@ class TagsController < ApplicationController TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) end + current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) + current_tag_name.association(:wiki_page).reset + current_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true) @@ -797,7 +801,6 @@ class TagsController < ApplicationController alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) end - current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) tag.update!(tag_name_id: promoted_tag_name.id) current_tag_name.association(:wiki_page).reset current_tag_name.association(:tag).reset @@ -815,7 +818,9 @@ class TagsController < ApplicationController affected_tags = [tag] - primary_tag_name = tag.tag_name(locale.language_code) + 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 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/tag.rb b/backend/app/models/tag.rb index e449781..fa659ee 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -64,6 +64,7 @@ class Tag < ApplicationRecord validates :category, presence: true, inclusion: { in: Tag.categories.keys } validate :category_must_be_deerjikist_with_deerjikists + validate :tag_names_must_be_valid CATEGORY_PREFIXES = { 'general:' => :general, @@ -77,10 +78,22 @@ class Tag < ApplicationRecord 'mtr:' => :material, 'meta:' => :meta }.freeze - def tag_name(language_code) = tag_names.find_by(language_code:, primary_flg: true) + 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? def has_wiki = wiki_page.present? @@ -232,6 +245,7 @@ class Tag < ApplicationRecord tag = Tag.create!(tag_name_id: tn.id, category:) tn.update!(tag:) + tag.association(:tag_names).reset tag rescue ActiveRecord::RecordNotUnique @@ -307,6 +321,14 @@ class Tag < ApplicationRecord private + def tag_names_must_be_valid + tag_names.each do |tag_name| + next if tag_name.valid? + + tag_name.errors[:name].each { errors.add :name, _1 } + end + end + def category_must_be_deerjikist_with_deerjikists if !(deerjikist?) && deerjikists.exists? errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index abd0dd2..4784526 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -91,7 +91,7 @@ module PostRepr post .post_tags .reject { _1.tag.deprecated? } - .sort_by { _1.tag.name(language_code) } + .sort_by { TagRepr.name_for(_1.tag, language_code) } .map { |post_tag| TagRepr.inline(post_tag.tag, language_code:).merge( 'children' => [], diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index d54e743..9e3a42e 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -8,14 +8,18 @@ module TagRepr module_function def base tag, language_code: 'ja' - tag.as_json(BASE).merge(name: tag.name(language_code), + 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, language_code: 'ja' - tag.as_json(BASE).merge(name: tag.name(language_code), aliases: [], parents: []) + tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: []) end 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/post_import_previewer.rb b/backend/app/services/post_import_previewer.rb index a81a6b6..2431ded 100644 --- a/backend/app/services/post_import_previewer.rb +++ b/backend/app/services/post_import_previewer.rb @@ -463,7 +463,7 @@ class PostImportPreviewer 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/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index d5debc8..025c3d4 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -15,23 +15,24 @@ class TagVersionRecorder < VersionRecorder def snapshot_attributes names = snapshot_names - japanese_names = names.select { _1[:language_code] == 'ja' } + japanese_names = names.select { _1['language_code'] == 'ja' } compatibility_names = japanese_names.presence || names - { name: compatibility_names.find { _1[:primary_flg] }[:name], + { name: compatibility_names.find { _1['primary_flg'] }['name'], category: @record.category, deprecated_at: @record.deprecated_at, - aliases: compatibility_names.reject { _1[:primary_flg] }.map { _1[:name] }.join(' '), + aliases: compatibility_names.reject { _1['primary_flg'] }.map { _1['name'] }.join(' '), names:, parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } end def snapshot_names @record.tag_names.order(:language_code, primary_flg: :desc).order(:name).map { |tag_name| - { language_code: tag_name.language_code, - name: tag_name.name, - script_code: tag_name.script_code, - primary_flg: tag_name.primary_flg } + { 'language_code' => tag_name.language_code, + 'name' => tag_name.name, + 'script_code' => tag_name.script_code, + 'primary_flg' => tag_name.primary_flg, + 'auto_generated' => tag_name.auto_generated } } end end diff --git a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb index 4a84eb2..c64f9eb 100644 --- a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb +++ b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb @@ -13,7 +13,8 @@ class AddNamesToTagVersions < ActiveRecord::Migration[8.0] language_code: 'ja', name: version.name, script_code: 'Jpan', - primary_flg: true }] + primary_flg: true, + auto_generated: false }] names.concat(version.aliases.to_s.split.filter_map { |name| next if name.blank? @@ -21,7 +22,8 @@ class AddNamesToTagVersions < ActiveRecord::Migration[8.0] language_code: 'ja', name:, script_code: 'Jpan', - primary_flg: false } + primary_flg: false, + auto_generated: false } }) version.update_columns(names: names) end diff --git a/backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb b/backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb new file mode 100644 index 0000000..79cb3cc --- /dev/null +++ b/backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb @@ -0,0 +1,19 @@ +class AddAutoGeneratedToTagNames < ActiveRecord::Migration[8.0] + class TagVersion < ActiveRecord::Base + self.table_name = 'tag_versions' + end + + def up + add_column :tag_names, :auto_generated, :boolean, null: false, default: false + TagVersion.reset_column_information + + TagVersion.find_each do |version| + names = Array(version.names).map { |name| name.merge('auto_generated' => false) } + version.update_columns(names: names) + end + end + + def down + remove_column :tag_names, :auto_generated + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 8862e73..bab1a8e 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_22_040000) do +ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) 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 @@ -454,6 +454,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do 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.boolean "auto_generated", default: false, null: false t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true diff --git a/backend/spec/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index f4d6890..0e98e3b 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -3,6 +3,7 @@ FactoryBot.define do language_code { 'ja' } script_code { 'Jpan' } primary_flg { true } + auto_generated { false } sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" } trait :alias do diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index 9bec5d7..f97562c 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -23,13 +23,13 @@ RSpec.describe TagVersionRecorder do expect(version.names).to eq([ { 'language_code' => 'en', 'name' => 'English name', 'script_code' => 'Latn', - 'primary_flg' => true }, + 'primary_flg' => true, 'auto_generated' => false }, { 'language_code' => 'en', 'name' => 'English alias', 'script_code' => 'Latn', - 'primary_flg' => false }, + 'primary_flg' => false, 'auto_generated' => false }, { 'language_code' => 'ja', 'name' => '日本語名', 'script_code' => 'Jpan', - 'primary_flg' => true }, + 'primary_flg' => true, 'auto_generated' => false }, { 'language_code' => 'ja', 'name' => '別名', 'script_code' => 'Jpan', - 'primary_flg' => false } + 'primary_flg' => false, 'auto_generated' => false } ]) end @@ -44,4 +44,13 @@ RSpec.describe TagVersionRecorder do hash_including('language_code' => 'en', 'name' => 'English name')) expect(updated.names).to include(hash_including('language_code' => 'en', 'name' => 'Renamed')) end + + it 'does not create a version for an unchanged multilingual snapshot' do + 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 -- 2.53.0 From 3dd13171fc58037a814c04cf264eda688f22dbef Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 28 Sep 2026 01:39:26 +0900 Subject: [PATCH 14/21] #55 --- .../models/tag_name_sanitisation_rule_spec.rb | 4 +- backend/spec/models/tag_name_spec.rb | 16 +++++++ backend/spec/models/tag_spec.rb | 47 +++++++++++++++++-- .../spec/requests/locale_write_paths_spec.rb | 2 - backend/spec/requests/tag_versions_spec.rb | 7 +-- backend/spec/requests/tags_spec.rb | 38 ++++++++++----- .../services/tag_version_recorder_spec.rb | 11 ++--- 7 files changed, 96 insertions(+), 29 deletions(-) diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 558869e..dcecd06 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -62,7 +62,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do name: 'foobar', language_code: 'en', tag_id: english.id) expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes( name: 'foobar', language_code: 'ja', tag_id: japanese.id) - expect(english.reload.tag_name_id).to eq(english_name.id) + expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name) end end @@ -109,7 +109,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do 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(source_tag.reload.tag_name_id).to eq(existing.id) + 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 diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 37107cc..aabcf0a 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -67,6 +67,22 @@ RSpec.describe TagName, type: :model do end describe '.canonicalise' do + it 'leaves detached aliases unchanged' do + create(:tag_name, :alias, name: 'detached_alias') + + expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) + .to eq(['detached_alias']) + end + + it 'leaves an alias unchanged when its language has no primary' do + tag = create(:tag, primary_name: '日本語のみ') + create(:tag_name, :alias, tag:, name: 'orphan_english_alias', + language_code: 'en', script_code: 'Latn') + + expect(described_class.canonicalise(locale, ['orphan_english_alias'])) + .to eq(['orphan_english_alias']) + end + it 'resolves the primary name of the alias language on the same tag' do tag = create(:tag, primary_name: '日本語正本') english_primary = create(:tag_name, tag:, name: 'english_primary', diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index d900053..27c8565 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -331,6 +331,40 @@ RSpec.describe Tag, type: :model 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 + english_primary = create(:tag_name, tag: target_tag, + name: 'target_english', language_code: 'en', script_code: 'Latn') + source_primary = create(:tag_name, tag: source_tag, + name: 'source_english', language_code: 'en', script_code: 'Latn') + source_alias = create(:tag_name, :alias, tag: source_tag, + name: 'source_english_alias', language_code: 'en', script_code: 'Latn') + + described_class.merge_tags!(target_tag, [source_tag]) + + expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary) + expect([source_primary.reload, source_alias.reload]).to all(have_attributes( + tag_id: target_tag.id, language_code: 'en', primary_flg: false)) + end + + it 'inherits a source primary when the target has no name in that language' do + source_primary = create(:tag_name, tag: source_tag, + name: 'inherited_english', language_code: 'en', script_code: 'Latn') + source_alias = create(:tag_name, :alias, tag: source_tag, + name: 'inherited_alias', language_code: 'en', script_code: 'Latn') + + described_class.merge_tags!(target_tag, [source_tag]) + + expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(source_primary) + expect(source_primary.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'en', primary_flg: true) + expect(source_alias.reload).to have_attributes( + tag_id: target_tag.id, language_code: 'en', primary_flg: false) + end + end + context 'when merging a simple source tag' do let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) } @@ -388,13 +422,20 @@ 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).to include( + hash_including('language_code' => 'ja', 'name' => source_name, + 'primary_flg' => true, 'auto_generated' => false), + hash_including('language_code' => 'ja', 'name' => source_alias.name, + 'primary_flg' => false, 'auto_generated' => false)) expect(Tag.unscoped.exists?(source_tag.id)).to be(false) target_versions = target_tag.tag_versions.order(:version_no) expect(target_versions.pluck(:event_type)).to eq(['create', 'update']) - expect(target_versions.last.aliases.split).to contain_exactly('merge_alias', source_name) + target_aliases = target_versions.last.names.filter_map { |name| + name['name'] if name['language_code'] == 'ja' && !name['primary_flg'] + } + expect(target_aliases).to contain_exactly('merge_alias', source_name) end it 'deletes source relationships while preserving unrelated relationships' do diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index 6c0ef8f..fa3b032 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -14,7 +14,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(tag_name).to have_attributes( script_code: expected_locale.script_code, primary_flg: true) expect(tag_name.tag_id).to be_present - expect(tag_name.tag.tag_name_id).to eq(tag_name.id) tag_name.tag end @@ -206,7 +205,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do .to have_attributes( script_code: 'Latn', tag_id: tag.id, primary_flg: false, language_code: 'en') - expect(tag.reload.tag_name_id).to eq(english_name.id) 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 diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 3a6383a..eebeca1 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -36,10 +36,11 @@ RSpec.describe 'TagVersions API', type: :request do category: category, deprecated_at: deprecated_at, aliases: Array(aliases).join(' '), - names: [{ language_code: 'ja', name: name, script_code: 'Jpan', - primary_flg: true }] + Array(aliases).map { |alias_name| + names: [{ language_code: 'ja', name:, script_code: 'Jpan', + primary_flg: true, + auto_generated: false }] + Array(aliases).map { |alias_name| { language_code: 'ja', name: alias_name, script_code: 'Jpan', - primary_flg: false } + primary_flg: false, auto_generated: false } }, parent_tag_ids: Array(parent_tags).map(&:id).join(' '), created_by_user: created_by_user, diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 07478a0..be64b53 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -18,6 +18,18 @@ RSpec.describe 'Tags API', type: :request do response_tags.map { |t| t.fetch('name') } end + def version_primary_name(version, language_code) + version.names.find { |name| + name['language_code'] == language_code && name['primary_flg'] + }.fetch('name') + end + + def version_alias_names(version, language_code) + version.names.filter_map { |name| + name['name'] if name['language_code'] == language_code && !name['primary_flg'] + } + 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 @@ -687,11 +699,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 @@ -1261,13 +1273,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 @@ -1307,8 +1319,8 @@ RSpec.describe 'Tags API', type: :request do 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 @@ -1490,10 +1502,11 @@ RSpec.describe 'Tags API', type: :request do 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 @@ -1536,7 +1549,8 @@ RSpec.describe 'Tags API', type: :request do 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(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 diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index f97562c..c556d87 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -9,13 +9,10 @@ RSpec.describe TagVersionRecorder do Script.insert_all!([{ code: 'Latn', name: 'Latin', created_at: Time.current }]) Locale.insert_all!([{ code: 'en', language_code: 'en', script_code: 'Latn', name: 'English', created_at: Time.current }]) - TagName.create!(tag:, language_code: 'ja', script_code: 'Jpan', name: '別名', - primary_flg: false, - canonical_id: tag.tag_name_id) - TagName.create!(tag:, language_code: 'en', script_code: 'Latn', name: 'English name', - primary_flg: true) - TagName.create!(tag:, language_code: 'en', script_code: 'Latn', name: 'English alias', - primary_flg: false) + create(:tag_name, :alias, tag:, name: '別名') + create(:tag_name, tag:, language_code: 'en', script_code: 'Latn', name: 'English name') + create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn', + name: 'English alias') end it 'snapshots every language primary name and alias deterministically' do -- 2.53.0 From 1e398bfb97c0b8681460b952adbeb90c39e61970 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 30 Sep 2026 00:30:05 +0900 Subject: [PATCH 15/21] #55 --- backend/app/models/language.rb | 2 + backend/app/models/locale.rb | 23 +- backend/app/models/script.rb | 4 +- backend/app/models/tag.rb | 485 +++++++++--------- backend/app/models/tag_name.rb | 2 +- backend/spec/models/locale_spec.rb | 17 +- .../models/tag_name_sanitisation_rule_spec.rb | 12 +- backend/spec/models/tag_name_spec.rb | 23 +- backend/spec/models/tag_spec.rb | 84 +-- .../spec/requests/locale_write_paths_spec.rb | 40 +- .../spec/services/post_create_plan_spec.rb | 20 +- .../services/tag_version_recorder_spec.rb | 9 +- backend/spec/support/locale_context.rb | 10 +- 13 files changed, 385 insertions(+), 346 deletions(-) diff --git a/backend/app/models/language.rb b/backend/app/models/language.rb index 1572f28..87f86d8 100644 --- a/backend/app/models/language.rb +++ b/backend/app/models/language.rb @@ -1,3 +1,5 @@ class Language < ApplicationRecord has_many :locales, foreign_key: :language_code, primary_key: :code + + enum :code, ja: 'ja', en: 'en', fr: 'fr' end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index a2081f6..1d39f44 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -4,21 +4,24 @@ class Locale < ApplicationRecord 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! - tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) - Tag.where.not(id: tag_ids).find_each do - TagName.create!(tag_id: _1.id, - language_code:, - name: TagName.generate_name(self, _1, _1.name('ja')), - script_code:, - primary_flg: true, - # TODO: 公証実装したら書く. - # auto_generated: true, - canonical_id: nil) + 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/script.rb b/backend/app/models/script.rb index 43abe1f..d87f262 100644 --- a/backend/app/models/script.rb +++ b/backend/app/models/script.rb @@ -1,3 +1,5 @@ class Script < ApplicationRecord - ; + has_many :locales, foreign_key: :language_code, primary_key: :code + + enum :code, Jpan: 'Jpan', Latn: 'Latn' end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index fa659ee..ff758ff 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -102,209 +102,6 @@ class Tag < ApplicationRecord def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? - def self.tagme = find_or_create_by_tag_name!(Locale.nipponese, 'タグ希望', category: :meta) - def self.bot = find_or_create_by_tag_name!(Locale.nipponese, 'bot操作', category: :meta) - def self.no_deerjikist = - find_or_create_by_tag_name!(Locale.nipponese, 'ニジラー情報不詳', category: :meta) - def self.video = find_or_create_by_tag_name!(Locale.nipponese, '動画', category: :meta) - def self.niconico = find_or_create_by_tag_name!(Locale.nipponese, 'ニコニコ', category: :meta) - def self.youtube = find_or_create_by_tag_name!(Locale.nipponese, 'YouTube', category: :meta) - - def self.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 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! locale, name, category: - language_code = locale.language_code - name = name.to_s.strip - - tn = TagName.find_or_create_by!(language_code:, name:) do - _1.script_code = locale.script_code - _1.primary_flg = true - end - - tag = tn.tag - return tag if tag - - tag = Tag.create!(tag_name_id: tn.id, category:) - tn.update!(tag:) - tag.association(:tag_names).reset - - tag - rescue ActiveRecord::RecordNotUnique - 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('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 snapshot_aliases(language_code) tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) end @@ -319,6 +116,262 @@ class Tag < ApplicationRecord .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 + + tn = TagName.find_or_create_by!(language_code:, name:) do + _1.script_code = locale.script_code + _1.primary_flg = true + end + + tag = tn.tag + return tag if tag + + tag = Tag.create!(tag_name_id: tn.id, category:) + tn.update!(tag:) + generate_tag_names!(locale, tag, tn) + tag.association(:tag_names).reset + + tag + 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_names_must_be_valid @@ -334,30 +387,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 5d47460..0ce8223 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -32,7 +32,7 @@ class TagName < ApplicationRecord }.uniq end - def self.generate_name locale, tag, name + def self.generate_name locale, tag, base_tag_name # TODO: 言語ごとの自動命名ロジック完成したら書く. "Tag_##{ tag.id }" diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index 54304bf..852f66f 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -17,22 +17,23 @@ RSpec.describe Locale, type: :model do second = create(:tag, primary_name: 'second_existing_tag') allow(TagName).to receive(:generate_name).and_call_original allow(TagName).to receive(:generate_name) - .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja').name) + .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja')) .and_return('name_from_generator') locale = create_french_locale! expect(TagName).to have_received(:generate_name) - .with(locale, first, primary_tag_name_for(first, 'ja').name) + .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').name) + .with(locale, second, primary_tag_name_for(second, 'ja')) expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, language_code: 'fr', script_code: 'Latn', - primary_flg: true) + primary_flg: true, auto_generated: true) expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) .to have_attributes(tag_id: second.id, language_code: 'fr', - script_code: 'Latn', primary_flg: true) + script_code: 'Latn', primary_flg: true, + auto_generated: true) expect(TagName.where(language_code: 'fr', primary_flg: true, tag_id: [first.id, second.id]).count).to eq(2) end @@ -48,7 +49,8 @@ RSpec.describe Locale, type: :model do expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true)) .to contain_exactly(existing) expect(existing.reload).to have_attributes( - tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: true) + tag_id: tag.id, language_code: 'fr', script_code: 'Latn', + primary_flg: true, auto_generated: false) end it 'creates a primary name when the language has only an alias' do @@ -65,7 +67,8 @@ RSpec.describe Locale, type: :model do language_code: 'fr', script_code: 'Latn', primary_flg: false) generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', - script_code: 'Latn', primary_flg: true) + script_code: 'Latn', primary_flg: true, + auto_generated: true) expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true).count).to eq(1) end end diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index dcecd06..0790421 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -31,14 +31,14 @@ 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 - japanese = create(:tag_name, name: 'foobar') + nipponese = create(:tag_name, name: 'foobar') english = create(:tag_name, name: 'temporary', language_code: 'en', script_code: 'Latn') english.update_columns(name: 'foo_bar') @@ -46,11 +46,11 @@ RSpec.describe TagNameSanitisationRule, type: :model do expect { described_class.apply! }.not_to change(TagName, :count) expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en') - expect(japanese.reload).to have_attributes(name: 'foobar', language_code: 'ja') + expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja') end it 'renames without merging tags or deleting either language identity' do - japanese = create(:tag, primary_name: 'foobar') + 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) @@ -60,8 +60,8 @@ RSpec.describe TagNameSanitisationRule, type: :model do expect(english_name.reload).to have_attributes( name: 'foobar', language_code: 'en', tag_id: english.id) - expect(primary_tag_name_for(japanese.reload, 'ja')).to have_attributes( - name: 'foobar', language_code: 'ja', tag_id: japanese.id) + expect(primary_tag_name_for(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 diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index aabcf0a..959778a 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -20,7 +20,7 @@ RSpec.describe TagName, type: :model do expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) end - it 'creates Japanese factory names without requiring a tag' do + it 'creates Nipponese factory names without requiring a tag' do name = create(:tag_name) expect(name.reload).to have_attributes( @@ -74,15 +74,6 @@ RSpec.describe TagName, type: :model do .to eq(['detached_alias']) end - it 'leaves an alias unchanged when its language has no primary' do - tag = create(:tag, primary_name: '日本語のみ') - create(:tag_name, :alias, tag:, name: 'orphan_english_alias', - language_code: 'en', script_code: 'Latn') - - expect(described_class.canonicalise(locale, ['orphan_english_alias'])) - .to eq(['orphan_english_alias']) - end - it 'resolves the primary name of the alias language on the same tag' do tag = create(:tag, primary_name: '日本語正本') english_primary = create(:tag_name, tag:, name: 'english_primary', @@ -102,18 +93,18 @@ RSpec.describe TagName, type: :model do end it 'resolves only aliases in the requested language' do - japanese = create(:tag, primary_name: 'japanese_canonical') + 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) - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) + 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(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + 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, @@ -121,7 +112,7 @@ RSpec.describe TagName, type: :model do expect(described_class.canonicalise(locale, ['shared_alias'])) .to eq(['english_canonical']) expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) - .to eq(['japanese_canonical']) + .to eq(['nipponese_canonical']) end end end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 27c8565..f9d1934 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -28,22 +28,21 @@ RSpec.describe Tag, type: :model do it 'canonicalises aliases in the supplied language only' do Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } - Locale.insert_all!([ - { code: 'en', language_code: 'en', script_code: 'Latn', - name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en') - english_locale = Locale.find('en') - japanese = create(:tag, primary_name: 'japanese_canonical') + 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) - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) + create(:tag_name, tag: english, name: 'english_in_nipponese') + english_locale = Locale.create!( + code: 'en', language_code: 'en', script_code: 'Latn', name: 'English') + 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(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + 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, @@ -53,7 +52,7 @@ RSpec.describe Tag, type: :model do with_tagme: false, with_no_deerjikist: false)).to eq([english]) expect(described_class.normalise_tags!( Locale.nipponese, ['shared_alias'], - with_tagme: false, with_no_deerjikist: false)).to eq([japanese]) + with_tagme: false, with_no_deerjikist: false)).to eq([nipponese]) end it 'rejects deprecated tags when deny_deprecated is enabled' do @@ -228,21 +227,46 @@ RSpec.describe Tag, type: :model do expect(tag.category).to eq('character') end + it 'uses the English primary as the source for a generated Nipponese name' do + 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 + 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 separate tag identities for the same name in different languages' do - japanese = described_class.find_or_create_by_tag_name!( + 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(japanese) + 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(japanese, 'ja')).to have_attributes( - name: 'same_name', tag_id: japanese.id, language_code: 'ja', 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(japanese) + Locale.nipponese, 'same_name', category: :general)).to eq(nipponese) end it 'preserves the V1 representative when looking up another primary language' do @@ -335,33 +359,25 @@ RSpec.describe Tag, type: :model do include_context 'English locale' it 'keeps the target primary and moves source names as aliases' do - english_primary = create(:tag_name, tag: target_tag, - name: 'target_english', language_code: 'en', script_code: 'Latn') - source_primary = create(:tag_name, tag: source_tag, - name: 'source_english', language_code: 'en', script_code: 'Latn') + 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)) - end - - it 'inherits a source primary when the target has no name in that language' do - source_primary = create(:tag_name, tag: source_tag, - name: 'inherited_english', language_code: 'en', script_code: 'Latn') - source_alias = create(:tag_name, :alias, tag: source_tag, - name: 'inherited_alias', language_code: 'en', script_code: 'Latn') - - described_class.merge_tags!(target_tag, [source_tag]) - - expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(source_primary) - expect(source_primary.reload).to have_attributes( - tag_id: target_tag.id, language_code: 'en', primary_flg: true) - expect(source_alias.reload).to have_attributes( - tag_id: target_tag.id, language_code: 'en', primary_flg: false) + 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 diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index fa3b032..83a3a3b 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do let(:locale_params) { requested_locale ? { locale: requested_locale } : { } } let(:expected_locale) { requested_locale ? locale : Locale.nipponese } - it 'creates post tags in the requested language or Japanese fallback' do + it 'creates post tags in the requested language or Nipponese fallback' do post '/posts', params: locale_params.merge( title: 'Locale post', url: 'https://example.com/locale-post', tags: 'locale_post_tag', parent_post_ids: '') @@ -32,7 +32,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(Post.find(json.fetch('id')).tags).to include(tag) end - it 'updates post tags in the requested language or Japanese fallback' do + it 'updates post tags in the requested language or Nipponese fallback' do record = create(:post) PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) @@ -110,8 +110,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') end let!(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) } - let!(:japanese_tag) do - create(:tag, primary_name: 'temporary_japanese', category: :general).tap do |tag| + 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 @@ -125,9 +125,9 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:created), response.body record = Post.find(json.fetch('id')) expect(record.tags).to include(english_tag) - expect(record.tags).not_to include(japanese_tag) + expect(record.tags).not_to include(nipponese_tag) expect(english_tag.reload.category).to eq('material') - expect(japanese_tag.reload.category).to eq('general') + expect(nipponese_tag.reload.category).to eq('general') end it 'updates the post using the English identity' do @@ -140,7 +140,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:ok), response.body expect(record.reload.tags).to include(english_tag) - expect(record.tags).not_to include(japanese_tag) + expect(record.tags).not_to include(nipponese_tag) end it 'resolves the English material tag' do @@ -149,7 +149,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do expect(response).to have_http_status(:created), response.body expect(Material.find(json.fetch('id')).tag).to eq(english_tag) - expect(Material.where(tag: japanese_tag)).to be_empty + expect(Material.where(tag: nipponese_tag)).to be_empty end it 'resolves the English parent tag' do @@ -182,16 +182,16 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end let!(:tag) { create(:tag, primary_tag_name: english_name) } - it 'allows a rename to a name already used in Japanese' do - japanese = create(:tag, primary_name: 'rename_target') + it 'allows a rename to a name already used in Nipponese' do + 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(japanese.reload, 'ja')).to have_attributes( - name: 'rename_target', language_code: 'ja', tag_id: japanese.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 @@ -210,11 +210,11 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end it 'does not take an alias from another language' do - japanese = create(:tag, primary_name: 'japanese_owner') - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + 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: { @@ -223,15 +223,15 @@ RSpec.describe 'Locale propagation on write paths', type: :request do aliases: 'shared_alias', parent_tags: '' } expect(response).to have_http_status(:ok), response.body - expect(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + 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', japanese.id], ['en', tag.id]) + .to contain_exactly(['ja', nipponese.id], ['en', tag.id]) end end end diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 5ff805a..c5521b3 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -17,16 +17,16 @@ RSpec.describe PostCreatePlan do english_name = create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name, category: :character) - japanese = create(:tag, primary_name: 'temporary_japanese', category: :general) + nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general) # The DB permits this identity; validation has its own contract spec. - primary_tag_name_for(japanese, 'ja').update_columns(name: 'shared_name') + 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) - japanese_parent = create(:tag, primary_name: 'japanese_parent', category: :general) + nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general) TagImplication.create!(tag: english, parent_tag: english_parent) - TagImplication.create!(tag: japanese, parent_tag: japanese_parent) + TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent) plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale) @@ -35,22 +35,22 @@ RSpec.describe PostCreatePlan do 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(japanese_parent, 'ja').name) + .not_to include(primary_tag_name_for(nipponese_parent, 'ja').name) end it 'resolves aliases within the requested language' do - japanese = create(:tag, primary_name: 'japanese_canonical') + 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) - japanese_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: japanese) + 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(japanese_alias.reload).to have_attributes( - tag_id: japanese.id, + 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, diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index c556d87..9e62183 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -2,15 +2,12 @@ require 'rails_helper' RSpec.describe TagVersionRecorder do let(:user) { create_member_user! } - let(:tag) { create(:tag, primary_name: '日本語名') } + let!(:tag) { create(:tag, primary_name: '日本語名') } + include_context 'English locale' before do - Language.insert_all!([{ code: 'en', name: 'English', created_at: Time.current }]) - Script.insert_all!([{ code: 'Latn', name: 'Latin', created_at: Time.current }]) - Locale.insert_all!([{ code: 'en', language_code: 'en', script_code: 'Latn', - name: 'English', created_at: Time.current }]) create(:tag_name, :alias, tag:, name: '別名') - create(:tag_name, tag:, language_code: 'en', script_code: 'Latn', name: 'English name') + 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 diff --git a/backend/spec/support/locale_context.rb b/backend/spec/support/locale_context.rb index fa873ef..92ef3db 100644 --- a/backend/spec/support/locale_context.rb +++ b/backend/spec/support/locale_context.rb @@ -2,12 +2,10 @@ RSpec.shared_context 'English locale' do let!(:locale) do Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } - unless Locale.exists?(code: 'en') - # Reference data only: do not generate names for unrelated existing tags. - Locale.insert_all!([ - { code: 'en', language_code: 'en', script_code: 'Latn', - name: 'English', created_at: Time.current }]) + Locale.find_or_create_by!(code: 'en') do |record| + record.language_code = 'en' + record.script_code = 'Latn' + record.name = 'English' end - Locale.find('en') end end -- 2.53.0 From 75cc6ac9a21b2943e44fe302c714744ec2d5758c Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 30 Sep 2026 01:14:58 +0900 Subject: [PATCH 16/21] #55 --- ...20260922030000_add_columns_to_tag_names.rb | 3 ++ ...0260922040000_add_names_to_tag_versions.rb | 28 ++++++++----------- ...2050000_add_auto_generated_to_tag_names.rb | 19 ------------- backend/db/schema.rb | 4 +-- 4 files changed, 16 insertions(+), 38 deletions(-) delete mode 100644 backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb diff --git a/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb b/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb index 083a414..6f203ca 100644 --- a/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb +++ b/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb @@ -7,6 +7,8 @@ class AddColumnsToTagNames < ActiveRecord::Migration[8.0] 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: true, 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 @@ -51,6 +53,7 @@ class AddColumnsToTagNames < ActiveRecord::Migration[8.0] 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 diff --git a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb index c64f9eb..af7e5a7 100644 --- a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb +++ b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb @@ -9,23 +9,17 @@ class AddNamesToTagVersions < ActiveRecord::Migration[8.0] say_with_time 'Backfilling multilingual tag version names' do TagVersion.find_each do |version| - names = [{ - language_code: 'ja', - name: version.name, - script_code: 'Jpan', - primary_flg: true, - auto_generated: false }] - names.concat(version.aliases.to_s.split.filter_map { |name| - next if name.blank? - - { - language_code: 'ja', - name:, - script_code: 'Jpan', - primary_flg: false, - auto_generated: false } - }) - version.update_columns(names: names) + aliases = version.aliases.to_s.split.filter_map do |name| + name.present? && + { name:, + script_code: 'Jpan', + auto_generated: false } + end + names = { ja: { name: version.name, + script_code: 'Jpan', + auto_generated: false, + aliases: } } + version.update_columns(names:) end end diff --git a/backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb b/backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb deleted file mode 100644 index 79cb3cc..0000000 --- a/backend/db/migrate/20260922050000_add_auto_generated_to_tag_names.rb +++ /dev/null @@ -1,19 +0,0 @@ -class AddAutoGeneratedToTagNames < ActiveRecord::Migration[8.0] - class TagVersion < ActiveRecord::Base - self.table_name = 'tag_versions' - end - - def up - add_column :tag_names, :auto_generated, :boolean, null: false, default: false - TagVersion.reset_column_information - - TagVersion.find_each do |version| - names = Array(version.names).map { |name| name.merge('auto_generated' => false) } - version.update_columns(names: names) - end - end - - def down - remove_column :tag_names, :auto_generated - end -end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index bab1a8e..238aac4 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_22_050000) 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 @@ -450,11 +450,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_050000) do t.string "name", null: false t.string "script_code", limit: 4, null: false t.boolean "primary_flg", null: false + t.boolean "auto_generated", default: true, 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.boolean "auto_generated", default: false, null: false t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true -- 2.53.0 From 9d6de2995513f09241c5e60e4f5c442f27de514e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 1 Oct 2026 12:45:10 +0900 Subject: [PATCH 17/21] #55 --- backend/spec/factories/tags.rb | 12 +++ backend/spec/models/language_spec.rb | 23 ++++++ backend/spec/models/locale_spec.rb | 77 ++++++++++++------- backend/spec/models/script_spec.rb | 20 +++++ backend/spec/models/tag_name_spec.rb | 36 ++++++++- backend/spec/models/tag_spec.rb | 64 ++++++++++++++- .../spec/requests/locale_write_paths_spec.rb | 45 +++++++++++ 7 files changed, 246 insertions(+), 31 deletions(-) create mode 100644 backend/spec/models/language_spec.rb create mode 100644 backend/spec/models/script_spec.rb diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index b362e23..d4ac59b 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -24,6 +24,18 @@ FactoryBot.define do 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..7f76a80 --- /dev/null +++ b/backend/spec/models/language_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe Language, type: :model do + it 'accepts supported language codes' do + expect(described_class.new(code: 'en', name: 'English')).to be_valid + 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 + + it 'does not treat French as a supported language' do + expect { + described_class.create!(code: 'fr', name: 'French') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end +end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index 852f66f..a4d2794 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -1,15 +1,15 @@ require 'rails_helper' RSpec.describe Locale, type: :model do - def prepare_french_reference! - Language.find_or_create_by!(code: 'fr') { _1.name = 'French' } + def prepare_english_reference! + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } end - def create_french_locale! - prepare_french_reference! - described_class.create!(code: 'fr', language_code: 'fr', - script_code: 'Latn', name: 'French') + def create_english_locale! + prepare_english_reference! + described_class.create!(code: 'en', language_code: 'en', + script_code: 'Latn', name: 'English') end it 'generates a primary name for every existing tag using the name generator' do @@ -20,55 +20,80 @@ RSpec.describe Locale, type: :model do .with(kind_of(described_class), first, primary_tag_name_for(first, 'ja')) .and_return('name_from_generator') - locale = create_french_locale! + 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: 'fr', primary_flg: true)) + 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: 'fr', script_code: 'Latn', + language_code: 'en', script_code: 'Latn', primary_flg: true, auto_generated: true) - expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) - .to have_attributes(tag_id: second.id, language_code: 'fr', + 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: 'fr', primary_flg: 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 - prepare_french_reference! + prepare_english_reference! tag = create(:tag, primary_name: 'already_named') - existing = create(:tag_name, name: 'nom_existant', tag:, - language_code: 'fr', script_code: 'Latn') + existing = create(:tag_name, name: 'existing_english', tag:, + language_code: 'en', script_code: 'Latn') - create_french_locale! + create_english_locale! - expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true)) + 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: 'fr', script_code: 'Latn', + 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 - prepare_french_reference! + prepare_english_reference! tag = create(:tag, primary_name: 'alias_only_tag') - alias_name = create(:tag_name, :alias, name: 'alias_fr', + alias_name = create(:tag_name, :alias, name: 'alias_en', tag:, - language_code: 'fr', script_code: 'Latn') + language_code: 'en', script_code: 'Latn') - create_french_locale! + create_english_locale! expect(alias_name.reload).to have_attributes( tag_id: tag.id, - language_code: 'fr', script_code: 'Latn', primary_flg: false) - generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) - expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', + 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: 'fr', primary_flg: true).count).to eq(1) + 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 + 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 + prepare_english_reference! + + locale = described_class.create!( + code: 'en-x-rspec', language_code: 'en', + script_code: 'Latn', name: 'English') + + expect(locale.reload).to have_attributes(code: 'en-x-rspec', language_code: 'en') end end diff --git a/backend/spec/models/script_spec.rb b/backend/spec/models/script_spec.rb new file mode 100644 index 0000000..ccd91ff --- /dev/null +++ b/backend/spec/models/script_spec.rb @@ -0,0 +1,20 @@ +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('Latn').locales).to include(locale) + expect(described_class.find('Jpan').locales).to include(Locale.nipponese) + expect(described_class.find('Jpan').locales).not_to include(locale) + end + + it 'accepts supported script codes and rejects unsupported ones' do + expect(described_class.new(code: 'Latn', name: 'Latin')).to be_valid + expect { + described_class.create!(code: 'Zzzz', name: 'Unsupported') + }.to raise_error { |error| + expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) + } + end +end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 959778a..8d01412 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -39,6 +39,18 @@ RSpec.describe TagName, type: :model do expect(tag.reload.tag_name_id).to eq(name.id) end + it 'creates a complete ordinary tag factory under every active 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)) + 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 @@ -74,10 +86,18 @@ RSpec.describe TagName, type: :model do .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 tag = create(:tag, primary_name: '日本語正本') - english_primary = create(:tag_name, tag:, name: 'english_primary', - language_code: 'en', script_code: 'Latn') + 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 @@ -115,4 +135,16 @@ RSpec.describe TagName, type: :model do .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 f9d1934..7493d2a 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -32,7 +32,8 @@ RSpec.describe Tag, type: :model do english_name = create(:tag_name, name: 'english_canonical', language_code: 'en', script_code: 'Latn') english = create(:tag, primary_tag_name: english_name) - create(:tag_name, tag: english, name: 'english_in_nipponese') + primary_tag_name_for(english, 'ja').update!( + name: 'english_in_nipponese', auto_generated: false) english_locale = Locale.create!( code: 'en', language_code: 'en', script_code: 'Latn', name: 'English') nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', @@ -252,6 +253,63 @@ RSpec.describe Tag, type: :model do 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 + 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', 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 + 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 + 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 + 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 nipponese = described_class.find_or_create_by_tag_name!( Locale.nipponese, 'same_name', category: :general) @@ -272,8 +330,8 @@ RSpec.describe Tag, type: :model do it 'preserves the V1 representative when looking up another primary language' do tag = create(:tag, primary_name: '日本語代表名') representative_id = tag.tag_name_id - english = create(:tag_name, name: 'english_primary', tag:, - language_code: 'en', script_code: 'Latn') + 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) diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index 83a3a3b..b5eb012 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -182,6 +182,51 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end let!(:tag) { create(:tag, primary_tag_name: english_name) } + it 'marks a human-renamed generated primary as no longer auto-generated' do + 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 + 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 nipponese = create(:tag, primary_name: 'rename_target') -- 2.53.0 From 0b9fa03f6d82ee728c18a4e39e4a0183d14c7eac Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 1 Oct 2026 22:56:15 +0900 Subject: [PATCH 18/21] #55 --- .../migrate/20260922010000_create_scripts.rb | 5 ++- .../migrate/20260922013000_create_regions.rb | 25 +++++++++++ .../migrate/20260922020000_create_locales.rb | 17 ++++++- ...20260922030000_add_columns_to_tag_names.rb | 9 +++- ...0260922040000_add_names_to_tag_versions.rb | 44 +++++++++++++++++-- backend/db/schema.rb | 21 ++++++++- 6 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 backend/db/migrate/20260922013000_create_regions.rb diff --git a/backend/db/migrate/20260922010000_create_scripts.rb b/backend/db/migrate/20260922010000_create_scripts.rb index 1ebb5ac..b825c84 100644 --- a/backend/db/migrate/20260922010000_create_scripts.rb +++ b/backend/db/migrate/20260922010000_create_scripts.rb @@ -4,13 +4,16 @@ class CreateScripts < ActiveRecord::Migration[8.0] 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 }) + ('Jpan', '漢字、仮名文字およびラテン文字', #{ connection.quote Time.current }) SQL 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 index afcd475..c396b69 100644 --- a/backend/db/migrate/20260922020000_create_locales.rb +++ b/backend/db/migrate/20260922020000_create_locales.rb @@ -3,19 +3,32 @@ class CreateLocales < ActiveRecord::Migration[8.0] 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, name, created_at) + locales(code, language_code, script_code, region_code, name, created_at) VALUES - ('ja', 'ja', 'Jpan', '日本語', #{ connection.quote Time.current }) + ('ja', 'ja', 'Jpan', 392, '日本語', #{ connection.quote Time.current }) SQL 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 index 6f203ca..0924d82 100644 --- a/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb +++ b/backend/db/migrate/20260922030000_add_columns_to_tag_names.rb @@ -8,7 +8,7 @@ class AddColumnsToTagNames < ActiveRecord::Migration[8.0] add_column :tag_names, :primary_flg, :boolean, null: false, after: :script_code, default: true add_column :tag_names, :auto_generated, :boolean, - null: false, default: true, after: :primary_flg + 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 @@ -41,9 +41,16 @@ class AddColumnsToTagNames < ActiveRecord::Migration[8.0] 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 diff --git a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb index af7e5a7..817b6d4 100644 --- a/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb +++ b/backend/db/migrate/20260922040000_add_names_to_tag_versions.rb @@ -10,10 +10,7 @@ class AddNamesToTagVersions < ActiveRecord::Migration[8.0] 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', - auto_generated: false } + name.present? && { name:, script_code: 'Jpan' } end names = { ja: { name: version.name, script_code: 'Jpan', @@ -23,10 +20,49 @@ class AddNamesToTagVersions < ActiveRecord::Migration[8.0] 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 238aac4..56b70a0 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -148,12 +148,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do 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}))*)?$',_utf8mb4'c')", name: "locales_variants_check" end create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -406,11 +411,22 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) 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| @@ -450,7 +466,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.string "name", null: false t.string "script_code", limit: 4, null: false t.boolean "primary_flg", null: false - t.boolean "auto_generated", default: true, 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 @@ -460,6 +476,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do 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| @@ -487,6 +504,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do 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| @@ -737,6 +755,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do 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" -- 2.53.0 From 860cd82175a9ee8b6dbaeac94ef7de805f3f9d33 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 1 Oct 2026 23:21:03 +0900 Subject: [PATCH 19/21] #55 --- backend/spec/db/tag_name_constraints_spec.rb | 36 ++++++++ backend/spec/db/tag_version_names_spec.rb | 91 +++++++++++++++++++ backend/spec/models/language_spec.rb | 25 +++-- backend/spec/models/locale_spec.rb | 44 +++++++-- backend/spec/models/script_spec.rb | 23 ++++- .../models/tag_name_sanitisation_rule_spec.rb | 4 + backend/spec/models/tag_name_spec.rb | 25 ++++- backend/spec/models/tag_spec.rb | 62 ++++++++++--- backend/spec/models/version_record_spec.rb | 14 ++- .../spec/requests/locale_write_paths_spec.rb | 91 ++++++++++++++++++- backend/spec/requests/tag_versions_spec.rb | 10 +- backend/spec/requests/tags_spec.rb | 8 +- .../spec/services/post_bulk_creator_spec.rb | 23 +++++ .../spec/services/post_create_plan_spec.rb | 4 + backend/spec/services/post_creator_spec.rb | 2 + .../services/tag_version_recorder_spec.rb | 89 ++++++++++++++---- backend/spec/support/locale_context.rb | 13 ++- 17 files changed, 485 insertions(+), 79 deletions(-) create mode 100644 backend/spec/db/tag_name_constraints_spec.rb create mode 100644 backend/spec/db/tag_version_names_spec.rb 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/models/language_spec.rb b/backend/spec/models/language_spec.rb index 7f76a80..9a2edcb 100644 --- a/backend/spec/models/language_spec.rb +++ b/backend/spec/models/language_spec.rb @@ -1,8 +1,15 @@ require 'rails_helper' RSpec.describe Language, type: :model do - it 'accepts supported language codes' do - expect(described_class.new(code: 'en', name: 'English')).to be_valid + 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 @@ -13,11 +20,13 @@ RSpec.describe Language, type: :model do } end - it 'does not treat French as a supported language' do - expect { - described_class.create!(code: 'fr', name: 'French') - }.to raise_error { |error| - expect(error).to be_a(ArgumentError).or be_a(ActiveRecord::RecordInvalid) - } + ['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 index a4d2794..62fbd55 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -1,18 +1,33 @@ require 'rails_helper' RSpec.describe Locale, type: :model 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' } - end + include_context 'English locale' def create_english_locale! prepare_english_reference! described_class.create!(code: 'en', language_code: 'en', - script_code: 'Latn', name: 'English') + 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 @@ -39,6 +54,7 @@ RSpec.describe Locale, type: :model do 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:, @@ -54,6 +70,7 @@ RSpec.describe Locale, type: :model do 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', @@ -73,6 +90,7 @@ RSpec.describe Locale, type: :model do 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') @@ -88,12 +106,24 @@ RSpec.describe Locale, type: :model do 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', name: 'English') + script_code: 'Latn', region_code: 826, + variants: 'x-rspec', name: 'English') - expect(locale.reload).to have_attributes(code: 'en-x-rspec', language_code: 'en') + 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/script_spec.rb b/backend/spec/models/script_spec.rb index ccd91ff..20365c1 100644 --- a/backend/spec/models/script_spec.rb +++ b/backend/spec/models/script_spec.rb @@ -4,17 +4,32 @@ RSpec.describe Script, type: :model do include_context 'English locale' it 'selects locales by script code, not language code' do - expect(described_class.find('Latn').locales).to include(locale) - expect(described_class.find('Jpan').locales).to include(Locale.nipponese) - expect(described_class.find('Jpan').locales).not_to include(locale) + 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.new(code: 'Latn', name: 'Latin')).to be_valid + 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_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 0790421..9cebc51 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -38,6 +38,8 @@ RSpec.describe TagNameSanitisationRule, type: :model 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') @@ -50,6 +52,8 @@ RSpec.describe TagNameSanitisationRule, type: :model do 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') diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 8d01412..8239d65 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -4,6 +4,8 @@ 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') @@ -24,7 +26,15 @@ RSpec.describe TagName, type: :model do name = create(:tag_name) expect(name.reload).to have_attributes( - language_code: 'ja', script_code: 'Jpan', primary_flg: true, tag_id: nil) + 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 @@ -39,7 +49,7 @@ RSpec.describe TagName, type: :model do expect(tag.reload.tag_name_id).to eq(name.id) end - it 'creates a complete ordinary tag factory under every active language' do + 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| @@ -47,6 +57,13 @@ RSpec.describe TagName, type: :model do 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 @@ -95,6 +112,8 @@ RSpec.describe TagName, type: :model do 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) @@ -113,6 +132,8 @@ RSpec.describe TagName, type: :model do 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') diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 7493d2a..1d08b42 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -25,17 +25,18 @@ RSpec.describe Tag, type: :model do end describe '.normalise_tags!' do + include_context 'English locale' + it 'canonicalises aliases in the supplied language only' do - Language.find_or_create_by!(code: 'en') { _1.name = 'English' } - Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + 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.create!( - code: 'en', language_code: 'en', script_code: 'Latn', name: 'English') + english_locale = locale nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: nipponese) english_alias = create(:tag_name, :alias, name: 'shared_alias', @@ -219,6 +220,8 @@ RSpec.describe Tag, type: :model 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) @@ -229,6 +232,8 @@ RSpec.describe Tag, type: :model do 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!( @@ -246,6 +251,8 @@ RSpec.describe Tag, type: :model do 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) @@ -254,10 +261,13 @@ RSpec.describe Tag, type: :model do 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', name: 'English') + 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) @@ -267,6 +277,8 @@ RSpec.describe Tag, type: :model do 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') @@ -282,6 +294,8 @@ RSpec.describe Tag, type: :model do 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 @@ -297,6 +311,8 @@ RSpec.describe Tag, type: :model do 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') @@ -311,6 +327,8 @@ RSpec.describe Tag, type: :model do 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!( @@ -328,6 +346,8 @@ RSpec.describe Tag, type: :model do 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') @@ -342,6 +362,8 @@ RSpec.describe Tag, type: :model do 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) @@ -363,6 +385,21 @@ RSpec.describe Tag, type: :model do expect(primary_tag_name_for(tag, 'ja').name).to eq('lookup_new') expect(tag.category).to eq('character') + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) + expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false) + end + + it 'promotes a detached Nipponese alias into a human-confirmed primary' do + detached = create(:tag_name, :alias, name: 'detached_nipponese') + + tag = described_class.find_or_create_by_tag_name!( + Locale.nipponese, detached.name, category: :general) + + expect(detached.reload).to have_attributes( + tag_id: tag.id, primary_flg: true, canonical_id: nil, + auto_generated: false) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(detached) end it 'reuses the owning tag for an alias without changing its category' do @@ -417,6 +454,8 @@ RSpec.describe Tag, type: :model 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, @@ -497,18 +536,15 @@ RSpec.describe Tag, type: :model do .to eq([[1, 'create'], [2, 'discard']]) expect(versions.first).to eq(original_version) expect(versions.last.created_by_user).to eq(user) - expect(versions.last.names).to include( - hash_including('language_code' => 'ja', 'name' => source_name, - 'primary_flg' => true, 'auto_generated' => false), - hash_including('language_code' => 'ja', 'name' => source_alias.name, - 'primary_flg' => false, 'auto_generated' => false)) + expect(versions.last.names.fetch('ja')).to eq( + 'name' => source_name, 'script_code' => 'Jpan', + 'auto_generated' => false, + 'aliases' => [{ 'name' => source_alias.name, 'script_code' => 'Jpan' }]) expect(Tag.unscoped.exists?(source_tag.id)).to be(false) target_versions = target_tag.tag_versions.order(:version_no) expect(target_versions.pluck(:event_type)).to eq(['create', 'update']) - target_aliases = target_versions.last.names.filter_map { |name| - name['name'] if name['language_code'] == 'ja' && !name['primary_flg'] - } + target_aliases = target_versions.last.names.fetch('ja').fetch('aliases').pluck('name') expect(target_aliases).to contain_exactly('merge_alias', source_name) end diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index b5cfffc..561c19b 100644 --- a/backend/spec/models/version_record_spec.rb +++ b/backend/spec/models/version_record_spec.rb @@ -12,12 +12,11 @@ RSpec.describe VersionRecord, type: :model do name: primary_tag_name_for(tag, 'ja').name, category: tag.category, aliases: '', - names: [{ language_code: 'ja', name: primary_tag_name_for(tag, 'ja').name, - script_code: 'Jpan', primary_flg: true }], + 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') @@ -32,12 +31,11 @@ RSpec.describe VersionRecord, type: :model do name: primary_tag_name_for(tag, 'ja').name, category: tag.category, aliases: '', - names: [{ language_code: 'ja', name: primary_tag_name_for(tag, 'ja').name, - script_code: 'Jpan', primary_flg: true }], + 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/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index b5eb012..48f2ed1 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -17,12 +17,51 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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: '') @@ -33,6 +72,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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) @@ -46,6 +87,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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: []) @@ -59,6 +102,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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') @@ -68,6 +113,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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') @@ -80,6 +127,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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) @@ -92,6 +141,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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( @@ -106,11 +157,11 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end context 'with existing names in both languages' do - let!(:english_name) 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 + 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') @@ -118,6 +169,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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: '' } @@ -131,6 +186,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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) @@ -144,6 +203,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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' } @@ -153,6 +216,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end it 'resolves the English parent tag' do + pending '日本語以外の Locale 有効化は別課題で実装する' + locale + english_tag + nipponese_tag child = create(:tag) put "/tags/#{ child.id }", params: { @@ -165,6 +232,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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) @@ -177,12 +248,14 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end context 'when renaming or updating aliases in English' do - let!(:english_name) 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) } + 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) @@ -196,6 +269,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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) @@ -228,6 +303,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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' } @@ -240,6 +317,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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, @@ -255,6 +334,8 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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) diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index eebeca1..f105773 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -36,12 +36,10 @@ RSpec.describe 'TagVersions API', type: :request do category: category, deprecated_at: deprecated_at, aliases: Array(aliases).join(' '), - names: [{ language_code: 'ja', name:, script_code: 'Jpan', - primary_flg: true, - auto_generated: false }] + Array(aliases).map { |alias_name| - { language_code: 'ja', name: alias_name, script_code: 'Jpan', - primary_flg: false, auto_generated: false } - }, + 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) diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index be64b53..c8bfd54 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -19,15 +19,11 @@ RSpec.describe 'Tags API', type: :request do end def version_primary_name(version, language_code) - version.names.find { |name| - name['language_code'] == language_code && name['primary_flg'] - }.fetch('name') + version.names.fetch(language_code).fetch('name') end def version_alias_names(version, language_code) - version.names.filter_map { |name| - name['name'] if name['language_code'] == language_code && !name['primary_flg'] - } + version.names.fetch(language_code).fetch('aliases').pluck('name') end def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy') diff --git a/backend/spec/services/post_bulk_creator_spec.rb b/backend/spec/services/post_bulk_creator_spec.rb index 9cd5d7b..d5167bb 100644 --- a/backend/spec/services/post_bulk_creator_spec.rb +++ b/backend/spec/services/post_bulk_creator_spec.rb @@ -4,6 +4,7 @@ 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) @@ -65,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 c5521b3..7d190ab 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -14,6 +14,8 @@ RSpec.describe PostCreatePlan 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) @@ -39,6 +41,8 @@ RSpec.describe PostCreatePlan do 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') diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb index 2fa36cc..624577d 100644 --- a/backend/spec/services/post_creator_spec.rb +++ b/backend/spec/services/post_creator_spec.rb @@ -81,6 +81,8 @@ RSpec.describe PostCreator do [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', diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index 9e62183..e403ef6 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -1,45 +1,96 @@ require 'rails_helper' RSpec.describe TagVersionRecorder do - let(:user) { create_member_user! } - let!(:tag) { create(:tag, primary_name: '日本語名') } include_context 'English locale' + let(:user) { create_member_user! } + let!(:tag) { create(:tag, primary_name: '日本語名') } + before do - create(:tag_name, :alias, tag:, name: '別名') + 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 every language primary name and alias deterministically' do + 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.names).to eq([ - { 'language_code' => 'en', 'name' => 'English name', 'script_code' => 'Latn', - 'primary_flg' => true, 'auto_generated' => false }, - { 'language_code' => 'en', 'name' => 'English alias', 'script_code' => 'Latn', - 'primary_flg' => false, 'auto_generated' => false }, - { 'language_code' => 'ja', 'name' => '日本語名', 'script_code' => 'Jpan', - 'primary_flg' => true, 'auto_generated' => false }, - { 'language_code' => 'ja', 'name' => '別名', 'script_code' => 'Jpan', - 'primary_flg' => false, 'auto_generated' => false } - ]) + expect(version.reload.names).to eq('ja' => nipponese_snapshot) end - it 'creates a version for an English-only name change and preserves prior names' do + it 'does not create a version for an unchanged Nipponese snapshot' do original = described_class.record!(tag:, event_type: :create, created_by_user: user) - TagName.find_by!(tag:, language_code: 'en', primary_flg: true).update!(name: 'Renamed') + + 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).to include( - hash_including('language_code' => 'en', 'name' => 'English name')) - expect(updated.names).to include(hash_including('language_code' => 'en', 'name' => 'Renamed')) + 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) diff --git a/backend/spec/support/locale_context.rb b/backend/spec/support/locale_context.rb index 92ef3db..3241f11 100644 --- a/backend/spec/support/locale_context.rb +++ b/backend/spec/support/locale_context.rb @@ -1,10 +1,21 @@ RSpec.shared_context 'English locale' do - let!(: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 -- 2.53.0 From 927ce90adc0ec5bec4ab539ac9e45adf54dd0f5f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 3 Oct 2026 01:30:26 +0900 Subject: [PATCH 20/21] #55 --- .../controllers/tag_versions_controller.rb | 13 ++++----- backend/app/controllers/tags_controller.rb | 14 +++++++--- backend/app/models/language.rb | 2 +- backend/app/models/region.rb | 3 ++ backend/app/models/script.rb | 4 +-- backend/app/models/tag.rb | 28 ++++++++++--------- backend/app/models/tag_name.rb | 9 +++++- backend/app/services/tag_version_recorder.rb | 26 +++++++++-------- backend/spec/tasks/nico_sync_spec.rb | 23 +++++++++------ 9 files changed, 74 insertions(+), 48 deletions(-) create mode 100644 backend/app/models/region.rb diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 2aab1db..f45d042 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -41,8 +41,8 @@ class TagVersionsController < ApplicationController rows.map do |row| current_names = names_for_locale(row.names, language_code) previous_names = names_for_locale(row.attributes['prev_names'], language_code) - cur_aliases = current_names.reject { _1['primary_flg'] }.map { _1['name'] } - prev_aliases = previous_names.reject { _1['primary_flg'] }.map { _1['name'] } + 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']) @@ -66,8 +66,7 @@ class TagVersionsController < ApplicationController version_no: row.version_no, event_type: row.event_type, names: row.names, - name: { current: current_names.find { _1['primary_flg'] }&.fetch('name'), - prev: previous_names.find { _1['primary_flg'] }&.fetch('name') }, + 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 }, @@ -98,10 +97,10 @@ 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) : Array(names) - values.select { _1['language_code'] == 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) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 58df873..9be0365 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -764,7 +764,7 @@ class TagsController < ApplicationController unless target_tag_name if current_tag_name - current_tag_name.update!(name:) + current_tag_name.update!(name:, auto_generated: false) else current_tag_name = TagName.create!( tag:, @@ -790,8 +790,9 @@ class TagsController < ApplicationController current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) current_tag_name.association(:wiki_page).reset - current_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) - promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true) + 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, @@ -832,6 +833,10 @@ class TagsController < ApplicationController alias_names.each do |alias_name| 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 @@ -851,7 +856,8 @@ class TagsController < ApplicationController language_code: locale.language_code, name: alias_name) alias_tag_name.script_code = locale.script_code - alias_tag_name.update!(tag:, primary_flg: false, canonical_id: primary_tag_name.id) + 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| diff --git a/backend/app/models/language.rb b/backend/app/models/language.rb index 87f86d8..fb2727f 100644 --- a/backend/app/models/language.rb +++ b/backend/app/models/language.rb @@ -1,5 +1,5 @@ class Language < ApplicationRecord has_many :locales, foreign_key: :language_code, primary_key: :code - enum :code, ja: 'ja', en: 'en', fr: 'fr' + enum :code, ja: 'ja' end 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 index d87f262..bbc23f1 100644 --- a/backend/app/models/script.rb +++ b/backend/app/models/script.rb @@ -1,5 +1,5 @@ class Script < ApplicationRecord - has_many :locales, foreign_key: :language_code, primary_key: :code + has_many :locales, foreign_key: :script_code, primary_key: :code - enum :code, Jpan: 'Jpan', Latn: 'Latn' + enum :code, Jpan: 'Jpan' end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index ff758ff..7891029 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -250,20 +250,22 @@ class Tag < ApplicationRecord language_code = locale.language_code name = name.to_s.strip - tn = TagName.find_or_create_by!(language_code:, name:) do - _1.script_code = locale.script_code - _1.primary_flg = true + 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 - - tag = tn.tag - return tag if tag - - tag = Tag.create!(tag_name_id: tn.id, category:) - tn.update!(tag:) - generate_tag_names!(locale, tag, tn) - tag.association(:tag_names).reset - - tag rescue ActiveRecord::RecordNotUnique retry end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 0ce8223..6c119ab 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -11,6 +11,7 @@ class TagName < ApplicationRecord 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 primary? = primary_flg @@ -22,7 +23,7 @@ class TagName < ApplicationRecord names.map { |name| tag_name = tns[name] - if !tag_name || tag_name.primary? + if !tag_name || tag_name.primary? || tag_name.tag_id.nil? name else TagName.find_by(language_code: locale.language_code, @@ -40,6 +41,12 @@ class TagName < ApplicationRecord private + 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 !(primary?) && name.to_s.include?(':') errors.add :name, 'エーリアス名にプレフィクスを含むことはできません.' diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index 025c3d4..e14d497 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -15,24 +15,28 @@ class TagVersionRecorder < VersionRecorder def snapshot_attributes names = snapshot_names - japanese_names = names.select { _1['language_code'] == 'ja' } - compatibility_names = japanese_names.presence || names + compatibility_names = names.fetch('ja') - { name: compatibility_names.find { _1['primary_flg'] }['name'], + { name: compatibility_names.fetch('name'), category: @record.category, deprecated_at: @record.deprecated_at, - aliases: compatibility_names.reject { _1['primary_flg'] }.map { _1['name'] }.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, primary_flg: :desc).order(:name).map { |tag_name| - { 'language_code' => tag_name.language_code, - 'name' => tag_name.name, - 'script_code' => tag_name.script_code, - 'primary_flg' => tag_name.primary_flg, - 'auto_generated' => tag_name.auto_generated } - } + @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/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 6ae8afa..db6d55a 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -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 -- 2.53.0 From a508ea6f8b8bf8fcc93690f6f1ab4298c40dde55 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 3 Oct 2026 01:42:54 +0900 Subject: [PATCH 21/21] #55 --- backend/db/schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 56b70a0..eee4568 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -158,7 +158,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do 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}))*)?$',_utf8mb4'c')", name: "locales_variants_check" + 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| -- 2.53.0