From b945ceef6056ad7ac84d77012eb550c62737eeb5 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 17 Oct 2026 16:47:08 +0900 Subject: [PATCH] #428 --- .../app/controllers/application_controller.rb | 17 +- backend/app/models/language.rb | 2 +- backend/app/models/locale.rb | 20 +- backend/app/models/script.rb | 2 +- backend/app/models/tag.rb | 42 +- backend/app/models/tag_name.rb | 12 +- backend/app/services/tag_name_completion.rb | 43 + .../app/services/tag_name_transcription.rb | 23 + .../japanese_segments.rb | 30 + .../tag_name_transcription/jis_readings.rb | 13 + .../tag_name_transcription/katakana.rb | 24 + .../tag_name_transcription/phonology.rb | 36 + .../tag_name_readings/jis_level_one.tsv | 2968 +++++++++++++++++ backend/db/seeds.rb | 10 + backend/spec/factories/tags.rb | 12 +- backend/spec/models/language_spec.rb | 5 +- backend/spec/models/locale_activation_spec.rb | 82 + backend/spec/models/locale_spec.rb | 92 +- backend/spec/models/script_spec.rb | 7 +- .../models/tag_name_sanitisation_rule_spec.rb | 26 +- backend/spec/models/tag_name_spec.rb | 54 +- backend/spec/models/tag_spec.rb | 137 +- .../spec/requests/locale_resolution_spec.rb | 54 + .../spec/requests/locale_write_paths_spec.rb | 139 +- .../spec/requests/tag_group_locale_spec.rb | 31 +- backend/spec/requests/wiki_locale_spec.rb | 40 +- .../spec/services/post_bulk_creator_spec.rb | 5 +- .../spec/services/post_create_plan_spec.rb | 40 +- backend/spec/services/post_creator_spec.rb | 7 +- .../spec/services/tag_name_completion_spec.rb | 68 + .../services/tag_name_transcription_spec.rb | 34 + .../services/tag_version_recorder_spec.rb | 31 +- backend/spec/support/ainu_locale_contract.rb | 11 +- backend/spec/support/locale_context.rb | 25 +- frontend/src/App.tsx | 87 +- frontend/src/components/MaterialSidebar.tsx | 22 +- frontend/src/components/PostEditForm.tsx | 4 +- frontend/src/components/PostFormTagsArea.tsx | 4 +- frontend/src/components/PrefetchLink.tsx | 16 +- frontend/src/components/TagDetailSidebar.tsx | 19 +- frontend/src/components/TagSearch.tsx | 8 +- frontend/src/components/TagSidebar.tsx | 8 +- frontend/src/components/TopNav.tsx | 134 +- frontend/src/components/WikiBody.tsx | 6 +- frontend/src/components/common/TagInput.tsx | 4 +- .../components/dialogues/DialogueProvider.tsx | 5 +- frontend/src/lib/api.ts | 15 +- frontend/src/lib/gekanator.ts | 46 +- frontend/src/lib/help.ts | 19 +- frontend/src/lib/keyboardShortcuts.ts | 5 +- frontend/src/lib/locales.test.ts | 55 + frontend/src/lib/locales.ts | 62 + frontend/src/lib/materials.ts | 55 +- frontend/src/lib/messages.ts | 38 + frontend/src/lib/posts.ts | 24 +- frontend/src/lib/prefetchers.ts | 115 +- frontend/src/lib/queryKeys.ts | 127 +- frontend/src/lib/tags.ts | 46 +- frontend/src/lib/useKeyboardShortcuts.tsx | 5 +- frontend/src/lib/useLocale.ts | 27 + frontend/src/lib/wiki.ts | 19 +- frontend/src/pages/GekanatorPage.tsx | 24 +- .../deerjikists/DeerjikistDetailPage.tsx | 8 +- frontend/src/pages/help/HelpChangesPage.tsx | 7 +- frontend/src/pages/help/HelpDetailPage.tsx | 13 +- frontend/src/pages/help/HelpDiffPage.tsx | 18 +- frontend/src/pages/help/HelpListPage.tsx | 7 +- .../pages/materials/MaterialDetailPage.tsx | 10 +- .../pages/materials/MaterialHistoryPage.tsx | 10 +- .../src/pages/materials/MaterialListPage.tsx | 10 +- .../src/pages/materials/MaterialNewPage.tsx | 12 +- .../MaterialSyncSuppressionsPage.tsx | 10 +- frontend/src/pages/posts/PostDetailPage.tsx | 8 +- frontend/src/pages/posts/PostHistoryPage.tsx | 16 +- .../src/pages/posts/PostImportReviewPage.tsx | 18 +- .../src/pages/posts/PostImportSourcePage.tsx | 5 +- frontend/src/pages/posts/PostListPage.tsx | 14 +- frontend/src/pages/posts/PostSearchPage.tsx | 10 +- frontend/src/pages/tags/NicoTagListPage.tsx | 12 +- frontend/src/pages/tags/TagDetailPage.tsx | 8 +- frontend/src/pages/tags/TagHistoryPage.tsx | 8 +- frontend/src/pages/tags/TagListPage.tsx | 10 +- .../src/pages/theatres/TheatreDetailPage.tsx | 38 +- frontend/src/pages/users/SettingPage.tsx | 13 +- frontend/src/pages/wiki/WikiDetailPage.tsx | 22 +- frontend/src/pages/wiki/WikiDiffPage.tsx | 6 +- frontend/src/pages/wiki/WikiEditPage.tsx | 14 +- frontend/src/pages/wiki/WikiHistoryPage.tsx | 6 +- frontend/src/pages/wiki/WikiNewPage.tsx | 10 +- frontend/src/pages/wiki/WikiSearchPage.tsx | 8 +- frontend/vite.config.ts | 2 +- 91 files changed, 4652 insertions(+), 822 deletions(-) create mode 100644 backend/app/services/tag_name_completion.rb create mode 100644 backend/app/services/tag_name_transcription.rb create mode 100644 backend/app/services/tag_name_transcription/japanese_segments.rb create mode 100644 backend/app/services/tag_name_transcription/jis_readings.rb create mode 100644 backend/app/services/tag_name_transcription/katakana.rb create mode 100644 backend/app/services/tag_name_transcription/phonology.rb create mode 100644 backend/config/tag_name_readings/jis_level_one.tsv create mode 100644 backend/spec/models/locale_activation_spec.rb create mode 100644 backend/spec/requests/locale_resolution_spec.rb create mode 100644 backend/spec/services/tag_name_completion_spec.rb create mode 100644 backend/spec/services/tag_name_transcription_spec.rb create mode 100644 frontend/src/lib/locales.test.ts create mode 100644 frontend/src/lib/locales.ts create mode 100644 frontend/src/lib/messages.ts create mode 100644 frontend/src/lib/useLocale.ts diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 0023bd5..0e247b5 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,6 +1,11 @@ class ApplicationController < ActionController::API + class UnsupportedLocale < StandardError + ; + end + rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique + rescue_from(UnsupportedLocale) { head :not_found } before_action :reject_banned_ip_address! before_action :authenticate_user @@ -93,5 +98,15 @@ class ApplicationController < ActionController::API value end - def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese + def resolve_locale! + code = params.fetch(:locale, 'ja') + unless code.is_a?(String) && Locale.codes.value?(code) + raise UnsupportedLocale + end + + locale = Locale.find_by(code:) + raise UnsupportedLocale unless locale && locale.deprecated_at.nil? + + locale + end end diff --git a/backend/app/models/language.rb b/backend/app/models/language.rb index fb2727f..140aa6b 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' + enum :code, ja: 'ja', ain: 'ain' end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index b06fab9..f342c5f 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -1,26 +1,20 @@ class Locale < ApplicationRecord - after_create_commit :generate_tag_names! + after_save :generate_tag_names!, if: :needs_tag_names? belongs_to :language, foreign_key: :language_code, primary_key: :code belongs_to :script, foreign_key: :script_code, primary_key: :code - enum :code, ja: 'ja', en: 'en', fr: 'fr' + enum :code, ja: 'ja', ain_kana: 'ain-Kana' def self.nipponese = Locale.find('ja') private + def needs_tag_names? + deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?) + end + def generate_tag_names! - ApplicationRecord.transaction do - tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) - Tag.where.not(id: tag_ids).includes(:tag_names).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) - end - end + TagNameCompletion.for_locale!(self) end end diff --git a/backend/app/models/script.rb b/backend/app/models/script.rb index bbc23f1..83bd7d1 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: :script_code, primary_key: :code - enum :code, Jpan: 'Jpan' + enum :code, jpan: 'Jpan', kana: 'Kana' end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index b89b31f..a0716f5 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -111,7 +111,12 @@ class Tag < ApplicationRecord def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def snapshot_aliases(language_code) - tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) + if tag_names.loaded? + tag_names.select { _1.language_code == language_code && !(_1.primary?) } + .sort_by(&:name).map(&:name) + else + tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) + end end def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) @@ -263,16 +268,19 @@ class Tag < ApplicationRecord next tn.tag if tn tag = Tag.create!(category:) - tn = TagName.create!(tag:, language_code:, name:, - script_code: locale.script_code, primary_flg: true, - auto_generated: false) - generate_tag_names!(locale, tag, tn) + TagName.create!(tag:, language_code:, name:, + script_code: locale.script_code, primary_flg: true, + auto_generated: false) + TagNameCompletion.for_tag!(tag) tag.association(:tag_names).reset tag end rescue ActiveRecord::RecordNotUnique - retry + existing = TagName.find_by(language_code:, name:) + raise unless existing + + existing.tag end def merge_tags! target_tag, source_tags, created_by_user: nil @@ -352,28 +360,6 @@ class Tag < ApplicationRecord 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) - end - end - end end private diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 05b6b92..a58aee9 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -34,9 +34,15 @@ class TagName < ApplicationRecord end def self.generate_name locale, tag, base_tag_name - # TODO: 言語ごとの自動命名ロジック完成したら書く. + fallback = "Tag_##{ tag.id }" + return fallback unless locale.language_code == 'ain' && base_tag_name&.language_code == 'ja' - "Tag_##{ tag.id }" + name = TagNameTranscription.ainu(base_tag_name.name) + candidate = new(tag:, name:, language_code: 'ain', script_code: 'Kana', + primary_flg: true, auto_generated: true) + candidate.valid? ? name : fallback + rescue TagNameTranscription::Unconvertible + fallback end private @@ -54,6 +60,8 @@ class TagName < ApplicationRecord end def name_must_be_sanitised + return if auto_generated && name == "Tag_##{ tag_id }" + if name? && name != TagNameSanitisationRule.sanitise(name) errors.add :name, '名前に使用できない文字が含まれてゐます.' end diff --git a/backend/app/services/tag_name_completion.rb b/backend/app/services/tag_name_completion.rb new file mode 100644 index 0000000..2e4c218 --- /dev/null +++ b/backend/app/services/tag_name_completion.rb @@ -0,0 +1,43 @@ +class TagNameCompletion + def self.for_locale! locale + named_tags = TagName.where(language_code: locale.language_code, primary_flg: true) + .select(:tag_id) + Tag.where.not(id: named_tags).find_each do |tag| + for_tag!(tag, locales: [locale]) + end + end + + def self.for_tag! tag, locales: nil + locales ||= Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code) + tag.with_lock do + primary_names = tag.tag_names.where(primary_flg: true).index_by(&:language_code) + locales.to_a.uniq(&:language_code).each do |locale| + next if primary_names.key?(locale.language_code) + + name = TagName.generate_name(locale, tag, primary_names['ja']) + primary_names[locale.language_code] = create_name!(locale, tag, name) + end + tag.association(:tag_names).reset + end + end + + def self.create_name! locale, tag, name + persist_name!(locale, tag, name) + rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error + fallback = "Tag_##{ tag.id }" + collision = error.is_a?(ActiveRecord::RecordNotUnique) || + error.record.errors.of_kind?(:name, :taken) + raise unless name != fallback && collision + + persist_name!(locale, tag, fallback) + end + private_class_method :create_name! + + def self.persist_name! locale, tag, name + TagName.transaction(requires_new: true) do + TagName.create!(tag:, name:, language_code: locale.language_code, + script_code: locale.script_code, primary_flg: true, auto_generated: true) + end + end + private_class_method :persist_name! +end diff --git a/backend/app/services/tag_name_transcription.rb b/backend/app/services/tag_name_transcription.rb new file mode 100644 index 0000000..239cbed --- /dev/null +++ b/backend/app/services/tag_name_transcription.rb @@ -0,0 +1,23 @@ +module TagNameTranscription + class Unconvertible < StandardError + ; + end + + def self.ainu name + previous_script = nil + JapaneseSegments.split(name).map { |script, text| + boundary = previous_script && script != previous_script && + JapaneseSegments.japanese?(previous_script) && JapaneseSegments.japanese?(script) + previous_script = script + reading = script == :kanji ? JisReadings.read(text) : text + output = + if script == :literal + text + else + Katakana.render(Phonology.adapt(reading)) + end + + boundary ? "_#{ output }" : output + }.join + end +end diff --git a/backend/app/services/tag_name_transcription/japanese_segments.rb b/backend/app/services/tag_name_transcription/japanese_segments.rb new file mode 100644 index 0000000..ce35fd8 --- /dev/null +++ b/backend/app/services/tag_name_transcription/japanese_segments.rb @@ -0,0 +1,30 @@ +module TagNameTranscription + module JapaneseSegments + JAPANESE_SCRIPTS = [:kanji, :hiragana, :katakana].freeze + + def self.japanese?(script) = JAPANESE_SCRIPTS.include?(script) + + def self.split name + segments = [] + name.to_s.scan(/\X/).each do |character| + script = classify(character, segments.last&.first) + if segments.last&.first == script + segments.last[1] << character + else + segments << [script, character.dup] + end + end + segments + end + + def self.classify character, previous_script + return previous_script if character == 'ー' && japanese?(previous_script) + return :kanji if character.match?(/\A\p{Han}\z/) + return :hiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/) + return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/) + return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/) + + raise Unconvertible + end + end +end diff --git a/backend/app/services/tag_name_transcription/jis_readings.rb b/backend/app/services/tag_name_transcription/jis_readings.rb new file mode 100644 index 0000000..eb961a7 --- /dev/null +++ b/backend/app/services/tag_name_transcription/jis_readings.rb @@ -0,0 +1,13 @@ +module TagNameTranscription + module JisReadings + SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv') + READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line| + character, reading, = line.split("\t") + [character, reading] + }.freeze + + def self.read text + text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join + end + end +end diff --git a/backend/app/services/tag_name_transcription/katakana.rb b/backend/app/services/tag_name_transcription/katakana.rb new file mode 100644 index 0000000..5bcb710 --- /dev/null +++ b/backend/app/services/tag_name_transcription/katakana.rb @@ -0,0 +1,24 @@ +module TagNameTranscription + module Katakana + ROWS = { + '' => 'アイウエオ', + 'k' => 'カキクケコ', + 's' => 'サシスセソ', + 'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'], + 't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'], + 'n' => 'ナニヌネノ', + 'h' => 'ハヒフヘホ', + 'p' => 'パピプペポ', + 'm' => 'マミムメモ', + 'r' => 'ラリルレロ' }.freeze + SYLLABLES = ROWS.flat_map { |onset, letters| + kana = letters.is_a?(String) ? letters.chars : letters + ['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] } + }.to_h.merge( + 'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ', + 'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚', + 'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze + + def self.render(sounds) = sounds.map { SYLLABLES.fetch(_1) }.join + end +end diff --git a/backend/app/services/tag_name_transcription/phonology.rb b/backend/app/services/tag_name_transcription/phonology.rb new file mode 100644 index 0000000..86923a0 --- /dev/null +++ b/backend/app/services/tag_name_transcription/phonology.rb @@ -0,0 +1,36 @@ +module TagNameTranscription + module Phonology + ROWS = { + 'アイウエオ' => ['a', 'i', 'u', 'e', 'o'], + 'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'], + 'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'], + 'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'], + 'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'], + 'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'], + 'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'], + 'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'], + 'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'], + 'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'], + 'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'], + 'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'], + 'ヤユヨ' => ['ya', 'yu', 'yo'], + 'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'], + 'ワ' => ['wa'], + 'ン' => ['n'], + 'ッ' => ['t'], + 'ㇰ' => ['k'], + 'ㇱㇲ' => ['s', 's'], + 'ㇳ' => ['t'], + 'ㇴ' => ['n'], + 'ㇺ' => ['m'], + 'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze + SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze + + def self.adapt reading + kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').tr('ャュョ', 'ヤユヨ') + kana.delete('ー').scan(/\X/).map { |character| + character == 'ㇷ゚' ? 'p' : SOUNDS.fetch(character) { raise Unconvertible } + } + end + end +end diff --git a/backend/config/tag_name_readings/jis_level_one.tsv b/backend/config/tag_name_readings/jis_level_one.tsv new file mode 100644 index 0000000..7686f45 --- /dev/null +++ b/backend/config/tag_name_readings/jis_level_one.tsv @@ -0,0 +1,2968 @@ +# Source: gitkatze/c344b0c40e88204660942d5c288e33a4 +# Revision: c6db924f9078752bd817bfb3ae4fd4dbb69d5019 +# character reading JIS code +亜 あ 3021 +唖 あ 3022 +娃 あ 3023 +阿 あ 3024 +哀 あい 3025 +愛 あい 3026 +挨 あい 3027 +姶 あい 3028 +逢 あう 3029 +葵 あおい 302A +茜 あかね 302B +穐 あき 302C +悪 あく 302D +握 あく 302E +渥 あく 302F +旭 あさひ 3030 +葦 あし 3031 +芦 あし 3032 +鯵 あじ 3033 +梓 あずさ 3034 +圧 あつ 3035 +斡 あつ 3036 +扱 あつ 3037 +宛 あて 3038 +姐 あね 3039 +虻 あぶ 303A +飴 あめ 303B +絢 あや 303C +綾 あや 303D +鮎 あゆ 303E +或 ある 303F +粟 あわ 3040 +袷 あわ 3041 +安 あん 3042 +庵 あん 3043 +按 あん 3044 +暗 あん 3045 +案 あん 3046 +闇 あん 3047 +鞍 あん 3048 +杏 あん 3049 +以 い 304A +伊 い 304B +位 い 304C +依 い 304D +偉 い 304E +囲 い 304F +夷 い 3050 +委 い 3051 +威 い 3052 +尉 い 3053 +惟 い 3054 +意 い 3055 +慰 い 3056 +易 い 3057 +椅 い 3058 +為 い 3059 +畏 い 305A +異 い 305B +移 い 305C +維 い 305D +緯 い 305E +胃 い 305F +萎 い 3060 +衣 い 3061 +謂 い 3062 +違 い 3063 +遺 い 3064 +医 い 3065 +井 い 3066 +亥 い 3067 +域 いき 3068 +育 いく 3069 +郁 いく 306A +磯 いく 306B +一 いち 306C +壱 いち 306D +溢 いつ 306E +逸 いつ 306F +稲 いね 3070 +茨 いばら 3071 +芋 いも 3072 +鰯 いわし 3073 +允 いん 3074 +印 いん 3075 +咽 いん 3076 +員 いん 3077 +因 いん 3078 +姻 いん 3079 +引 いん 307A +飲 いん 307B +淫 いん 307C +胤 いん 307D +蔭 いん 307E +院 いん 3121 +陰 いん 3122 +隠 いん 3123 +韻 いん 3124 +吋 いん 3125 +右 う 3126 +宇 う 3127 +烏 う 3128 +羽 う 3129 +迂 う 312A +雨 う 312B +卯 う 312C +鵜 う 312D +窺 うかがう 312E +丑 うし 312F +碓 うす 3130 +臼 うす 3131 +渦 うず 3132 +嘘 うそ 3133 +唄 うた 3134 +欝 うつ 3135 +蔚 うつ 3136 +鰻 うなぎ 3137 +姥 うば 3138 +厩 うま 3139 +浦 うら 313A +瓜 うり 313B +閏 うるう 313C +噂 うわさ 313D +云 うん 313E +運 うん 313F +雲 うん 3140 +荏 うん 3141 +餌 え 3142 +叡 えい 3143 +営 えい 3144 +嬰 えい 3145 +影 えい 3146 +映 えい 3147 +曳 えい 3148 +栄 えい 3149 +永 えい 314A +泳 えい 314B +洩 えい 314C +瑛 えい 314D +盈 えい 314E +穎 えい 314F +頴 えい 3150 +英 えい 3151 +衛 えい 3152 +詠 えい 3153 +鋭 えい 3154 +液 えき 3155 +疫 えき 3156 +益 えき 3157 +駅 えき 3158 +悦 えつ 3159 +謁 えつ 315A +越 えつ 315B +閲 えつ 315C +榎 えのき 315D +厭 えん 315E +円 えん 315F +園 えん 3160 +堰 えん 3161 +奄 えん 3162 +宴 えん 3163 +延 えん 3164 +怨 えん 3165 +掩 えん 3166 +援 えん 3167 +沿 えん 3168 +演 えん 3169 +炎 えん 316A +焔 えん 316B +煙 えん 316C +燕 えん 316D +猿 えん 316E +縁 えん 316F +艶 えん 3170 +苑 えん 3171 +薗 えん 3172 +遠 えん 3173 +鉛 えん 3174 +鴛 えん 3175 +塩 えん 3176 +於 お 3177 +汚 お 3178 +甥 おい 3179 +凹 おう 317A +央 おう 317B +奥 おう 317C +往 おう 317D +応 おう 317E +押 おう 3221 +旺 おう 3222 +横 おう 3223 +欧 おう 3224 +殴 おう 3225 +王 おう 3226 +翁 おう 3227 +襖 おう 3228 +鴬 おう 3229 +鴎 おう 322A +黄 おう 322B +岡 おか 322C +沖 おき 322D +荻 おぎ 322E +億 おく 322F +屋 おく 3230 +憶 おく 3231 +臆 おく 3232 +桶 おけ 3233 +牡 おす 3234 +乙 おつ 3235 +俺 おれ 3236 +卸 おん 3237 +恩 おん 3238 +温 おん 3239 +穏 おん 323A +音 おん 323B +下 か 323C +化 か 323D +仮 か 323E +何 か 323F +伽 か 3240 +価 か 3241 +佳 か 3242 +加 か 3243 +可 か 3244 +嘉 か 3245 +夏 か 3246 +嫁 か 3247 +家 か 3248 +寡 か 3249 +科 か 324A +暇 か 324B +果 か 324C +架 か 324D +歌 か 324E +河 か 324F +火 か 3250 +珂 か 3251 +禍 か 3252 +禾 か 3253 +稼 か 3254 +箇 か 3255 +花 か 3256 +苛 か 3257 +茄 か 3258 +荷 か 3259 +華 か 325A +菓 か 325B +蝦 か 325C +課 か 325D +嘩 か 325E +貨 か 325F +迦 か 3260 +過 か 3261 +霞 か 3262 +蚊 か 3263 +俄 が 3264 +峨 が 3265 +我 が 3266 +牙 が 3267 +画 が 3268 +臥 が 3269 +芽 が 326A +蛾 が 326B +賀 が 326C +雅 が 326D +餓 が 326E +駕 が 326F +介 かい 3270 +会 かい 3271 +解 かい 3272 +回 かい 3273 +塊 かい 3274 +壊 かい 3275 +廻 かい 3276 +快 かい 3277 +怪 かい 3278 +悔 かい 3279 +恢 かい 327A +懐 かい 327B +戒 かい 327C +拐 かい 327D +改 かい 327E +魁 かい 3321 +晦 かい 3322 +械 かい 3323 +海 かい 3324 +灰 かい 3325 +界 かい 3326 +皆 かい 3327 +絵 かい 3328 +芥 かい 3329 +蟹 かい 332A +開 かい 332B +階 かい 332C +貝 かい 332D +凱 がい 332E +劾 がい 332F +外 がい 3330 +咳 がい 3331 +害 がい 3332 +崖 がい 3333 +慨 がい 3334 +概 がい 3335 +涯 がい 3336 +碍 がい 3337 +蓋 がい 3338 +街 がい 3339 +該 がい 333A +鎧 がい 333B +骸 がい 333C +浬 かいり 333D +馨 かおる 333E +蛙 かえる 333F +垣 かき 3340 +柿 かき 3341 +蛎 かき 3342 +鈎 かぎ 3343 +劃 かく 3344 +嚇 かく 3345 +各 かく 3346 +廓 かく 3347 +拡 かく 3348 +撹 かく 3349 +格 かく 334A +核 かく 334B +殻 かく 334C +獲 かく 334D +確 かく 334E +穫 かく 334F +覚 かく 3350 +角 かく 3351 +赫 かく 3352 +較 かく 3353 +郭 かく 3354 +閣 かく 3355 +隔 かく 3356 +革 かく 3357 +学 がく 3358 +岳 がく 3359 +楽 がく 335A +額 がく 335B +顎 がく 335C +掛 かけ 335D +笠 かさ 335E +樫 かし 335F +橿 かし 3360 +梶 かじ 3361 +鰍 かじか 3362 +潟 かた 3363 +割 かつ 3364 +喝 かつ 3365 +恰 かつ 3366 +括 かつ 3367 +活 かつ 3368 +渇 かつ 3369 +滑 かつ 336A +葛 かつ 336B +褐 かつ 336C +轄 かつ 336D +且 かつ 336E +鰹 かつお 336F +叶 かな 3370 +椛 かば 3371 +樺 かば 3372 +鞄 かばん 3373 +株 かぶ 3374 +兜 かぶと 3375 +竃 かま 3376 +蒲 かま 3377 +釜 かま 3378 +鎌 かま 3379 +噛 かむ 337A +鴨 かも 337B +栢 かや 337C +茅 かや 337D +萱 かや 337E +粥 かゆ 3421 +刈 かり 3422 +苅 かり 3423 +瓦 かわら 3424 +乾 かん 3425 +侃 かん 3426 +冠 かん 3427 +寒 かん 3428 +刊 かん 3429 +勘 かん 342A +勧 かん 342B +巻 かん 342C +喚 かん 342D +堪 かん 342E +姦 かん 342F +完 かん 3430 +官 かん 3431 +寛 かん 3432 +干 かん 3433 +幹 かん 3434 +患 かん 3435 +感 かん 3436 +慣 かん 3437 +憾 かん 3438 +換 かん 3439 +敢 かん 343A +柑 かん 343B +桓 かん 343C +棺 かん 343D +款 かん 343E +歓 かん 343F +汗 かん 3440 +漢 かん 3441 +澗 かん 3442 +潅 かん 3443 +環 かん 3444 +甘 かん 3445 +監 かん 3446 +看 かん 3447 +竿 かん 3448 +管 かん 3449 +簡 かん 344A +緩 かん 344B +缶 かん 344C +翰 かん 344D +肝 かん 344E +艦 かん 344F +莞 かん 3450 +観 かん 3451 +諌 かん 3452 +貫 かん 3453 +還 かん 3454 +鑑 かん 3455 +間 かん 3456 +閑 かん 3457 +関 かん 3458 +陥 かん 3459 +韓 かん 345A +館 かん 345B +舘 かん 345C +丸 がん 345D +含 がん 345E +岸 がん 345F +巌 がん 3460 +玩 がん 3461 +癌 がん 3462 +眼 がん 3463 +岩 がん 3464 +翫 がん 3465 +贋 がん 3466 +雁 がん 3467 +頑 がん 3468 +顔 がん 3469 +願 がん 346A +企 き 346B +伎 き 346C +危 き 346D +喜 き 346E +器 き 346F +基 き 3470 +奇 き 3471 +嬉 き 3472 +寄 き 3473 +岐 き 3474 +希 き 3475 +幾 き 3476 +忌 き 3477 +揮 き 3478 +机 き 3479 +旗 き 347A +既 き 347B +期 き 347C +棋 き 347D +棄 き 347E +機 き 3521 +帰 き 3522 +毅 き 3523 +気 き 3524 +汽 き 3525 +畿 き 3526 +祈 き 3527 +季 き 3528 +稀 き 3529 +紀 き 352A +徽 き 352B +規 き 352C +記 き 352D +貴 き 352E +起 き 352F +軌 き 3530 +輝 き 3531 +飢 き 3532 +騎 き 3533 +鬼 き 3534 +亀 き 3535 +偽 ぎ 3536 +儀 ぎ 3537 +妓 ぎ 3538 +宜 ぎ 3539 +戯 ぎ 353A +技 ぎ 353B +擬 ぎ 353C +欺 ぎ 353D +犠 ぎ 353E +疑 ぎ 353F +祇 ぎ 3540 +義 ぎ 3541 +蟻 ぎ 3542 +誼 ぎ 3543 +議 ぎ 3544 +掬 きく 3545 +菊 きく 3546 +鞠 きく 3547 +吉 きち 3548 +吃 きつ 3549 +喫 きつ 354A +桔 きつ 354B +橘 きつ 354C +詰 きつ 354D +砧 きぬた 354E +杵 きね 354F +黍 きび 3550 +却 きゃく 3551 +客 きゃく 3552 +脚 きゃく 3553 +虐 ぎゃく 3554 +逆 ぎゃく 3555 +丘 きゅう 3556 +久 きゅう 3557 +仇 きゅう 3558 +休 きゅう 3559 +及 きゅう 355A +吸 きゅう 355B +宮 きゅう 355C +弓 きゅう 355D +急 きゅう 355E +救 きゅう 355F +朽 きゅう 3560 +求 きゅう 3561 +汲 きゅう 3562 +泣 きゅう 3563 +灸 きゅう 3564 +球 きゅう 3565 +究 きゅう 3566 +窮 きゅう 3567 +笈 きゅう 3568 +級 きゅう 3569 +糾 きゅう 356A +給 きゅう 356B +旧 きゅう 356C +牛 ぎゅう 356D +去 きょ 356E +居 きょ 356F +巨 きょ 3570 +拒 きょ 3571 +拠 きょ 3572 +挙 きょ 3573 +渠 きょ 3574 +虚 きょ 3575 +許 きょ 3576 +距 きょ 3577 +鋸 きょ 3578 +漁 ぎょ 3579 +禦 ぎょ 357A +魚 ぎょ 357B +亨 きょう 357C +享 きょう 357D +京 きょう 357E +供 きょう 3621 +侠 きょう 3622 +僑 きょう 3623 +兇 きょう 3624 +競 きょう 3625 +共 きょう 3626 +凶 きょう 3627 +協 きょう 3628 +匡 きょう 3629 +卿 きょう 362A +叫 きょう 362B +喬 きょう 362C +境 きょう 362D +峡 きょう 362E +強 きょう 362F +彊 きょう 3630 +怯 きょう 3631 +恐 きょう 3632 +恭 きょう 3633 +挟 きょう 3634 +教 きょう 3635 +橋 きょう 3636 +況 きょう 3637 +狂 きょう 3638 +狭 きょう 3639 +矯 きょう 363A +胸 きょう 363B +脅 きょう 363C +興 きょう 363D +蕎 きょう 363E +郷 きょう 363F +鏡 きょう 3640 +響 きょう 3641 +饗 きょう 3642 +驚 きょう 3643 +仰 ぎょう 3644 +凝 ぎょう 3645 +尭 ぎょう 3646 +暁 ぎょう 3647 +業 ぎょう 3648 +局 きょく 3649 +曲 きょく 364A +極 きょく 364B +玉 ぎょく 364C +桐 きり 364D +粁 きろ 364E +僅 きん 364F +勤 きん 3650 +均 きん 3651 +巾 きん 3652 +錦 きん 3653 +斤 きん 3654 +欣 きん 3655 +欽 きん 3656 +琴 きん 3657 +禁 きん 3658 +禽 きん 3659 +筋 きん 365A +緊 きん 365B +芹 きん 365C +菌 きん 365D +衿 きん 365E +襟 きん 365F +謹 きん 3660 +近 きん 3661 +金 きん 3662 +吟 ぎん 3663 +銀 ぎん 3664 +九 く 3665 +倶 く 3666 +句 く 3667 +区 く 3668 +狗 く 3669 +玖 く 366A +矩 く 366B +苦 く 366C +躯 く 366D +駆 く 366E +駈 く 366F +駒 く 3670 +具 ぐ 3671 +愚 ぐ 3672 +虞 ぐ 3673 +喰 くう 3674 +空 くう 3675 +偶 ぐう 3676 +寓 ぐう 3677 +遇 ぐう 3678 +隅 ぐう 3679 +串 くし 367A +櫛 くし 367B +釧 くしろ 367C +屑 くず 367D +屈 くつ 367E +掘 くつ 3721 +窟 くつ 3722 +沓 くつ 3723 +靴 くつ 3724 +轡 くつわ 3725 +窪 くぼ 3726 +熊 くま 3727 +隈 くま 3728 +粂 くめ 3729 +栗 くり 372A +繰 くる 372B +桑 くわ 372C +鍬 くわ 372D +勲 くん 372E +君 くん 372F +薫 くん 3730 +訓 くん 3731 +群 ぐん 3732 +軍 ぐん 3733 +郡 ぐん 3734 +卦 け 3735 +袈 け 3736 +祁 け 3737 +係 けい 3738 +傾 けい 3739 +刑 けい 373A +兄 けい 373B +啓 けい 373C +圭 けい 373D +珪 けい 373E +型 けい 373F +契 けい 3740 +形 けい 3741 +径 けい 3742 +恵 けい 3743 +慶 けい 3744 +慧 けい 3745 +憩 けい 3746 +掲 けい 3747 +携 けい 3748 +敬 けい 3749 +景 けい 374A +桂 けい 374B +渓 けい 374C +畦 けい 374D +稽 けい 374E +系 けい 374F +経 けい 3750 +継 けい 3751 +繋 けい 3752 +罫 けい 3753 +茎 けい 3754 +荊 けい 3755 +蛍 けい 3756 +計 けい 3757 +詣 けい 3758 +警 けい 3759 +軽 けい 375A +頚 けい 375B +鶏 けい 375C +芸 げい 375D +迎 げい 375E +鯨 げい 375F +劇 げき 3760 +戟 げき 3761 +撃 げき 3762 +激 げき 3763 +隙 げき 3764 +桁 けた 3765 +傑 けつ 3766 +欠 けつ 3767 +決 けつ 3768 +潔 けつ 3769 +穴 けつ 376A +結 けつ 376B +血 けつ 376C +訣 けつ 376D +月 げつ 376E +件 けん 376F +倹 けん 3770 +倦 けん 3771 +健 けん 3772 +兼 けん 3773 +券 けん 3774 +剣 けん 3775 +喧 けん 3776 +圏 けん 3777 +堅 けん 3778 +嫌 けん 3779 +建 けん 377A +憲 けん 377B +懸 けん 377C +拳 けん 377D +捲 けん 377E +検 けん 3821 +権 けん 3822 +牽 けん 3823 +犬 けん 3824 +献 けん 3825 +研 けん 3826 +硯 けん 3827 +絹 けん 3828 +県 けん 3829 +肩 けん 382A +見 けん 382B +謙 けん 382C +賢 けん 382D +軒 けん 382E +遣 けん 382F +鍵 けん 3830 +険 けん 3831 +顕 けん 3832 +験 けん 3833 +鹸 けん 3834 +元 げん 3835 +原 げん 3836 +厳 げん 3837 +幻 げん 3838 +弦 げん 3839 +減 げん 383A +源 げん 383B +玄 げん 383C +現 げん 383D +絃 げん 383E +舷 げん 383F +言 げん 3840 +諺 げん 3841 +限 げん 3842 +乎 こ 3843 +個 こ 3844 +古 こ 3845 +呼 こ 3846 +固 こ 3847 +姑 こ 3848 +孤 こ 3849 +己 こ 384A +庫 こ 384B +弧 こ 384C +戸 こ 384D +故 こ 384E +枯 こ 384F +湖 こ 3850 +狐 こ 3851 +糊 こ 3852 +袴 こ 3853 +股 こ 3854 +胡 こ 3855 +菰 こ 3856 +虎 こ 3857 +誇 こ 3858 +跨 こ 3859 +鈷 こ 385A +雇 こ 385B +顧 こ 385C +鼓 こ 385D +五 ご 385E +互 ご 385F +伍 ご 3860 +午 ご 3861 +呉 ご 3862 +吾 ご 3863 +娯 ご 3864 +後 ご 3865 +御 ご 3866 +悟 ご 3867 +梧 ご 3868 +檎 ご 3869 +瑚 ご 386A +碁 ご 386B +語 ご 386C +誤 ご 386D +護 ご 386E +醐 ご 386F +乞 こい 3870 +鯉 こい 3871 +交 こう 3872 +佼 こう 3873 +侯 こう 3874 +候 こう 3875 +倖 こう 3876 +光 こう 3877 +公 こう 3878 +功 こう 3879 +効 こう 387A +勾 こう 387B +厚 こう 387C +口 こう 387D +向 こう 387E +后 こう 3921 +喉 こう 3922 +坑 こう 3923 +垢 こう 3924 +好 こう 3925 +孔 こう 3926 +孝 こう 3927 +宏 こう 3928 +工 こう 3929 +巧 こう 392A +巷 こう 392B +幸 こう 392C +広 こう 392D +庚 こう 392E +康 こう 392F +弘 こう 3930 +恒 こう 3931 +慌 こう 3932 +抗 こう 3933 +拘 こう 3934 +控 こう 3935 +攻 こう 3936 +昂 こう 3937 +晃 こう 3938 +更 こう 3939 +杭 こう 393A +校 こう 393B +梗 こう 393C +構 こう 393D +江 こう 393E +洪 こう 393F +浩 こう 3940 +港 こう 3941 +溝 こう 3942 +甲 こう 3943 +皇 こう 3944 +硬 こう 3945 +稿 こう 3946 +糠 こう 3947 +紅 こう 3948 +紘 こう 3949 +絞 こう 394A +綱 こう 394B +耕 こう 394C +考 こう 394D +肯 こう 394E +肱 こう 394F +腔 こう 3950 +膏 こう 3951 +航 こう 3952 +荒 こう 3953 +行 こう 3954 +衡 こう 3955 +講 こう 3956 +貢 こう 3957 +購 こう 3958 +郊 こう 3959 +酵 こう 395A +鉱 こう 395B +砿 こう 395C +鋼 こう 395D +閤 こう 395E +降 こう 395F +項 こう 3960 +香 こう 3961 +高 こう 3962 +鴻 こう 3963 +剛 こう 3964 +劫 こう 3965 +号 ごう 3966 +合 ごう 3967 +壕 ごう 3968 +拷 ごう 3969 +濠 ごう 396A +豪 ごう 396B +轟 ごう 396C +麹 こうじ 396D +克 こく 396E +刻 こく 396F +告 こく 3970 +国 こく 3971 +穀 こく 3972 +酷 こく 3973 +鵠 こく 3974 +黒 こく 3975 +獄 ごく 3976 +漉 こし 3977 +腰 こし 3978 +甑 こしき 3979 +忽 こつ 397A +惚 こつ 397B +骨 こつ 397C +狛 こま 397D +込 こむ 397E +此 これ 3A21 +頃 ころ 3A22 +今 こん 3A23 +困 こん 3A24 +坤 こん 3A25 +墾 こん 3A26 +婚 こん 3A27 +恨 こん 3A28 +懇 こん 3A29 +昏 こん 3A2A +昆 こん 3A2B +根 こん 3A2C +梱 こん 3A2D +混 こん 3A2E +痕 こん 3A2F +紺 こん 3A30 +艮 こん 3A31 +魂 こん 3A32 +些 さ 3A33 +佐 さ 3A34 +叉 さ 3A35 +唆 さ 3A36 +嵯 さ 3A37 +左 さ 3A38 +差 さ 3A39 +査 さ 3A3A +沙 さ 3A3B +瑳 さ 3A3C +砂 さ 3A3D +詐 さ 3A3E +鎖 さ 3A3F +裟 さ 3A40 +坐 ざ 3A41 +座 ざ 3A42 +挫 ざ 3A43 +債 さい 3A44 +催 さい 3A45 +再 さい 3A46 +最 さい 3A47 +哉 さい 3A48 +塞 さい 3A49 +妻 さい 3A4A +宰 さい 3A4B +彩 さい 3A4C +才 さい 3A4D +採 さい 3A4E +栽 さい 3A4F +歳 さい 3A50 +済 さい 3A51 +災 さい 3A52 +采 さい 3A53 +犀 さい 3A54 +砕 さい 3A55 +砦 さい 3A56 +祭 さい 3A57 +斎 さい 3A58 +細 さい 3A59 +菜 さい 3A5A +裁 さい 3A5B +載 さい 3A5C +際 さい 3A5D +剤 ざい 3A5E +在 ざい 3A5F +材 ざい 3A60 +罪 ざい 3A61 +財 ざい 3A62 +冴 さえ 3A63 +坂 さか 3A64 +阪 さか 3A65 +堺 さかい 3A66 +榊 さかき 3A67 +肴 さかな 3A68 +咲 さき 3A69 +崎 さき 3A6A +埼 さき 3A6B +碕 さき 3A6C +鷺 さぎ 3A6D +作 さく 3A6E +削 さく 3A6F +咋 さく 3A70 +搾 さく 3A71 +昨 さく 3A72 +朔 さく 3A73 +柵 さく 3A74 +窄 さく 3A75 +策 さく 3A76 +索 さく 3A77 +錯 さく 3A78 +桜 さくら 3A79 +鮭 さけ 3A7A +笹 ささ 3A7B +匙 さじ 3A7C +冊 さつ 3A7D +刷 さつ 3A7E +察 さつ 3B21 +拶 さつ 3B22 +撮 さつ 3B23 +擦 さつ 3B24 +札 さつ 3B25 +殺 さつ 3B26 +薩 さつ 3B27 +雑 ざつ 3B28 +皐 さつき 3B29 +鯖 さば 3B2A +捌 さばく 3B2B +錆 さび 3B2C +鮫 さめ 3B2D +皿 さら 3B2E +晒 さらす 3B2F +三 さん 3B30 +傘 さん 3B31 +参 さん 3B32 +山 さん 3B33 +惨 さん 3B34 +撒 さん 3B35 +散 さん 3B36 +桟 さん 3B37 +燦 さん 3B38 +珊 さん 3B39 +産 さん 3B3A +算 さん 3B3B +纂 さん 3B3C +蚕 さん 3B3D +讃 さん 3B3E +賛 さん 3B3F +酸 さん 3B40 +餐 さん 3B41 +斬 ざん 3B42 +暫 ざん 3B43 +残 ざん 3B44 +仕 し 3B45 +仔 し 3B46 +伺 し 3B47 +使 し 3B48 +刺 し 3B49 +司 し 3B4A +史 し 3B4B +嗣 し 3B4C +四 し 3B4D +士 し 3B4E +始 し 3B4F +姉 し 3B50 +姿 し 3B51 +子 し 3B52 +屍 し 3B53 +市 し 3B54 +師 し 3B55 +志 し 3B56 +思 し 3B57 +指 し 3B58 +支 し 3B59 +孜 し 3B5A +斯 し 3B5B +施 し 3B5C +旨 し 3B5D +枝 し 3B5E +止 し 3B5F +死 し 3B60 +氏 し 3B61 +獅 し 3B62 +祉 し 3B63 +私 し 3B64 +糸 し 3B65 +紙 し 3B66 +紫 し 3B67 +肢 し 3B68 +脂 し 3B69 +至 し 3B6A +視 し 3B6B +詞 し 3B6C +詩 し 3B6D +試 し 3B6E +誌 し 3B6F +諮 し 3B70 +資 し 3B71 +賜 し 3B72 +雌 し 3B73 +飼 し 3B74 +歯 し 3B75 +事 じ 3B76 +似 じ 3B77 +侍 じ 3B78 +児 じ 3B79 +字 じ 3B7A +寺 じ 3B7B +慈 じ 3B7C +持 じ 3B7D +時 じ 3B7E +次 じ 3C21 +滋 じ 3C22 +治 じ 3C23 +爾 じ 3C24 +璽 じ 3C25 +痔 じ 3C26 +磁 じ 3C27 +示 じ 3C28 +而 じ 3C29 +耳 じ 3C2A +自 じ 3C2B +蒔 じ 3C2C +辞 じ 3C2D +汐 しお 3C2E +鹿 しか 3C2F +式 しき 3C30 +識 しき 3C31 +鴫 しぎ 3C32 +竺 じく 3C33 +軸 じく 3C34 +宍 じく 3C35 +雫 しずく 3C36 +七 しち 3C37 +叱 しつ 3C38 +執 しつ 3C39 +失 しつ 3C3A +嫉 しつ 3C3B +室 しつ 3C3C +悉 しつ 3C3D +湿 しつ 3C3E +漆 しつ 3C3F +疾 しつ 3C40 +質 しつ 3C41 +実 じつ 3C42 +蔀 しとみ 3C43 +篠 しの 3C44 +偲 しのぶ 3C45 +柴 しば 3C46 +芝 しば 3C47 +屡 しば 3C48 +蕊 しべ 3C49 +縞 しま 3C4A +舎 しゃ 3C4B +写 しゃ 3C4C +射 しゃ 3C4D +捨 しゃ 3C4E +赦 しゃ 3C4F +斜 しゃ 3C50 +煮 しゃ 3C51 +社 しゃ 3C52 +紗 しゃ 3C53 +者 しゃ 3C54 +謝 しゃ 3C55 +車 しゃ 3C56 +遮 しゃ 3C57 +蛇 じゃ 3C58 +邪 じゃ 3C59 +借 しゃく 3C5A +勺 しゃく 3C5B +尺 しゃく 3C5C +杓 しゃく 3C5D +灼 しゃく 3C5E +爵 しゃく 3C5F +酌 しゃく 3C60 +釈 しゃく 3C61 +錫 しゃく 3C62 +若 じゃく 3C63 +寂 じゃく 3C64 +弱 じゃく 3C65 +惹 じゃく 3C66 +主 しゅ 3C67 +取 しゅ 3C68 +守 しゅ 3C69 +手 しゅ 3C6A +朱 しゅ 3C6B +殊 しゅ 3C6C +狩 しゅ 3C6D +珠 しゅ 3C6E +種 しゅ 3C6F +腫 しゅ 3C70 +趣 しゅ 3C71 +酒 しゅ 3C72 +首 しゅ 3C73 +儒 じゅ 3C74 +受 じゅ 3C75 +呪 じゅ 3C76 +寿 じゅ 3C77 +授 じゅ 3C78 +樹 じゅ 3C79 +綬 じゅ 3C7A +需 じゅ 3C7B +囚 しゅう 3C7C +収 しゅう 3C7D +周 しゅう 3C7E +宗 しゅう 3D21 +就 しゅう 3D22 +州 しゅう 3D23 +修 しゅう 3D24 +愁 しゅう 3D25 +拾 しゅう 3D26 +洲 しゅう 3D27 +秀 しゅう 3D28 +秋 しゅう 3D29 +終 しゅう 3D2A +繍 しゅう 3D2B +習 しゅう 3D2C +臭 しゅう 3D2D +舟 しゅう 3D2E +蒐 しゅう 3D2F +衆 しゅう 3D30 +襲 しゅう 3D31 +讐 しゅう 3D32 +蹴 しゅう 3D33 +輯 しゅう 3D34 +週 しゅう 3D35 +酋 しゅう 3D36 +酬 しゅう 3D37 +集 しゅう 3D38 +醜 しゅう 3D39 +什 じゅう 3D3A +住 じゅう 3D3B +充 じゅう 3D3C +十 じゅう 3D3D +従 じゅう 3D3E +戎 じゅう 3D3F +柔 じゅう 3D40 +汁 じゅう 3D41 +渋 じゅう 3D42 +獣 じゅう 3D43 +縦 じゅう 3D44 +重 じゅう 3D45 +銃 じゅう 3D46 +叔 しゅく 3D47 +夙 しゅく 3D48 +宿 しゅく 3D49 +淑 しゅく 3D4A +祝 しゅく 3D4B +縮 しゅく 3D4C +粛 しゅく 3D4D +塾 じゅく 3D4E +熟 じゅく 3D4F +出 しゅつ 3D50 +術 じゅつ 3D51 +述 じゅつ 3D52 +俊 しゅん 3D53 +峻 しゅん 3D54 +春 しゅん 3D55 +瞬 しゅん 3D56 +竣 しゅん 3D57 +舜 しゅん 3D58 +駿 しゅん 3D59 +准 じゅん 3D5A +循 じゅん 3D5B +旬 じゅん 3D5C +楯 じゅん 3D5D +殉 じゅん 3D5E +淳 じゅん 3D5F +準 じゅん 3D60 +潤 じゅん 3D61 +盾 じゅん 3D62 +純 じゅん 3D63 +巡 じゅん 3D64 +遵 じゅん 3D65 +醇 じゅん 3D66 +順 じゅん 3D67 +処 しょ 3D68 +初 しょ 3D69 +所 しょ 3D6A +暑 しょ 3D6B +曙 しょ 3D6C +渚 しょ 3D6D +庶 しょ 3D6E +緒 しょ 3D6F +署 しょ 3D70 +書 しょ 3D71 +薯 しょ 3D72 +藷 しょ 3D73 +諸 しょ 3D74 +助 じょ 3D75 +叙 じょ 3D76 +女 じょ 3D77 +序 じょ 3D78 +徐 じょ 3D79 +恕 じょ 3D7A +鋤 じょ 3D7B +除 じょ 3D7C +傷 しょう 3D7D +償 しょう 3D7E +勝 しょう 3E21 +匠 しょう 3E22 +升 しょう 3E23 +召 しょう 3E24 +哨 しょう 3E25 +商 しょう 3E26 +唱 しょう 3E27 +嘗 しょう 3E28 +奨 しょう 3E29 +妾 しょう 3E2A +娼 しょう 3E2B +宵 しょう 3E2C +将 しょう 3E2D +小 しょう 3E2E +少 しょう 3E2F +尚 しょう 3E30 +庄 しょう 3E31 +床 しょう 3E32 +廠 しょう 3E33 +彰 しょう 3E34 +承 しょう 3E35 +抄 しょう 3E36 +招 しょう 3E37 +掌 しょう 3E38 +捷 しょう 3E39 +昇 しょう 3E3A +昌 しょう 3E3B +昭 しょう 3E3C +晶 しょう 3E3D +松 しょう 3E3E +梢 しょう 3E3F +樟 しょう 3E40 +樵 しょう 3E41 +沼 しょう 3E42 +消 しょう 3E43 +渉 しょう 3E44 +湘 しょう 3E45 +焼 しょう 3E46 +焦 しょう 3E47 +照 しょう 3E48 +症 しょう 3E49 +省 しょう 3E4A +硝 しょう 3E4B +礁 しょう 3E4C +祥 しょう 3E4D +称 しょう 3E4E +章 しょう 3E4F +笑 しょう 3E50 +粧 しょう 3E51 +紹 しょう 3E52 +肖 しょう 3E53 +菖 しょう 3E54 +蒋 しょう 3E55 +蕉 しょう 3E56 +衝 しょう 3E57 +裳 しょう 3E58 +訟 しょう 3E59 +証 しょう 3E5A +詔 しょう 3E5B +詳 しょう 3E5C +象 しょう 3E5D +賞 しょう 3E5E +醤 しょう 3E5F +鉦 しょう 3E60 +鍾 しょう 3E61 +鐘 しょう 3E62 +障 しょう 3E63 +鞘 しょう 3E64 +上 じょう 3E65 +丈 じょう 3E66 +丞 じょう 3E67 +乗 じょう 3E68 +冗 じょう 3E69 +剰 じょう 3E6A +城 じょう 3E6B +場 じょう 3E6C +壌 じょう 3E6D +嬢 じょう 3E6E +常 じょう 3E6F +情 じょう 3E70 +擾 じょう 3E71 +条 じょう 3E72 +杖 じょう 3E73 +浄 じょう 3E74 +状 じょう 3E75 +畳 じょう 3E76 +穣 じょう 3E77 +蒸 じょう 3E78 +譲 じょう 3E79 +醸 じょう 3E7A +錠 じょう 3E7B +嘱 しょく 3E7C +埴 しょく 3E7D +飾 しょく 3E7E +拭 しょく 3F21 +植 しょく 3F22 +殖 しょく 3F23 +燭 しょく 3F24 +織 しょく 3F25 +職 しょく 3F26 +色 しょく 3F27 +触 しょく 3F28 +食 しょく 3F29 +蝕 しょく 3F2A +辱 じょく 3F2B +尻 しり 3F2C +伸 しん 3F2D +信 しん 3F2E +侵 しん 3F2F +唇 しん 3F30 +娠 しん 3F31 +寝 しん 3F32 +審 しん 3F33 +心 しん 3F34 +慎 しん 3F35 +振 しん 3F36 +新 しん 3F37 +晋 しん 3F38 +森 しん 3F39 +榛 しん 3F3A +浸 しん 3F3B +深 しん 3F3C +申 しん 3F3D +疹 しん 3F3E +真 しん 3F3F +神 しん 3F40 +秦 しん 3F41 +紳 しん 3F42 +臣 しん 3F43 +芯 しん 3F44 +薪 しん 3F45 +親 しん 3F46 +診 しん 3F47 +身 しん 3F48 +辛 しん 3F49 +進 しん 3F4A +針 しん 3F4B +震 しん 3F4C +人 じん 3F4D +仁 じん 3F4E +刃 じん 3F4F +塵 じん 3F50 +壬 じん 3F51 +尋 じん 3F52 +甚 じん 3F53 +尽 じん 3F54 +腎 じん 3F55 +訊 じん 3F56 +迅 じん 3F57 +陣 じん 3F58 +靭 じん 3F59 +笥 す 3F5A +諏 す 3F5B +須 す 3F5C +酢 す 3F5D +図 ず 3F5E +厨 ず 3F5F +逗 ず 3F60 +吹 すい 3F61 +垂 すい 3F62 +帥 すい 3F63 +推 すい 3F64 +水 すい 3F65 +炊 すい 3F66 +睡 すい 3F67 +粋 すい 3F68 +翠 すい 3F69 +衰 すい 3F6A +遂 すい 3F6B +酔 すい 3F6C +錐 すい 3F6D +錘 すい 3F6E +随 ずい 3F6F +瑞 ずい 3F70 +髄 ずい 3F71 +崇 すう 3F72 +嵩 すう 3F73 +数 すう 3F74 +枢 すう 3F75 +趨 すう 3F76 +雛 すう 3F77 +据 すえ 3F78 +杉 すぎ 3F79 +椙 すぎ 3F7A +菅 すげ 3F7B +頗 すこぶる 3F7C +雀 すずめ 3F7D +裾 すそ 3F7E +澄 すむ 4021 +摺 すり 4022 +寸 すん 4023 +世 せ 4024 +瀬 せ 4025 +畝 せ 4026 +是 ぜ 4027 +凄 せい 4028 +制 せい 4029 +勢 せい 402A +姓 せい 402B +征 せい 402C +性 せい 402D +成 せい 402E +政 せい 402F +整 せい 4030 +星 せい 4031 +晴 せい 4032 +棲 せい 4033 +栖 せい 4034 +正 せい 4035 +清 せい 4036 +牲 せい 4037 +生 せい 4038 +盛 せい 4039 +精 せい 403A +聖 せい 403B +声 せい 403C +製 せい 403D +西 せい 403E +誠 せい 403F +誓 せい 4040 +請 せい 4041 +逝 せい 4042 +醒 せい 4043 +青 せい 4044 +静 せい 4045 +斉 せい 4046 +税 ぜい 4047 +脆 ぜい 4048 +隻 せき 4049 +席 せき 404A +惜 せき 404B +戚 せき 404C +斥 せき 404D +昔 せき 404E +析 せき 404F +石 せき 4050 +積 せき 4051 +籍 せき 4052 +績 せき 4053 +脊 せき 4054 +責 せき 4055 +赤 せき 4056 +跡 せき 4057 +蹟 せき 4058 +碩 せき 4059 +切 せつ 405A +拙 せつ 405B +接 せつ 405C +摂 せつ 405D +折 せつ 405E +設 せつ 405F +窃 せつ 4060 +節 せつ 4061 +説 せつ 4062 +雪 せつ 4063 +絶 ぜつ 4064 +舌 ぜつ 4065 +蝉 せみ 4066 +仙 せん 4067 +先 せん 4068 +千 せん 4069 +占 せん 406A +宣 せん 406B +専 せん 406C +尖 せん 406D +川 せん 406E +戦 せん 406F +扇 せん 4070 +撰 せん 4071 +栓 せん 4072 +栴 せん 4073 +泉 せん 4074 +浅 せん 4075 +洗 せん 4076 +染 せん 4077 +潜 せん 4078 +煎 せん 4079 +煽 せん 407A +旋 せん 407B +穿 せん 407C +箭 せん 407D +線 せん 407E +繊 せん 4121 +羨 せん 4122 +腺 せん 4123 +舛 せん 4124 +船 せん 4125 +薦 せん 4126 +詮 せん 4127 +賎 せん 4128 +践 せん 4129 +選 せん 412A +遷 せん 412B +銭 せん 412C +銑 せん 412D +閃 せん 412E +鮮 せん 412F +前 ぜん 4130 +善 ぜん 4131 +漸 ぜん 4132 +然 ぜん 4133 +全 ぜん 4134 +禅 ぜん 4135 +繕 ぜん 4136 +膳 ぜん 4137 +糎 せんち 4138 +噌 そ 4139 +塑 そ 413A +岨 そ 413B +措 そ 413C +曾 そ 413D +曽 そ 413E +楚 そ 413F +狙 そ 4140 +疏 そ 4141 +疎 そ 4142 +礎 そ 4143 +祖 そ 4144 +租 そ 4145 +粗 そ 4146 +素 そ 4147 +組 そ 4148 +蘇 そ 4149 +訴 そ 414A +阻 そ 414B +遡 そ 414C +鼠 そ 414D +僧 そう 414E +創 そう 414F +双 そう 4150 +叢 そう 4151 +倉 そう 4152 +喪 そう 4153 +壮 そう 4154 +奏 そう 4155 +爽 そう 4156 +宋 そう 4157 +層 そう 4158 +匝 そう 4159 +惣 そう 415A +想 そう 415B +捜 そう 415C +掃 そう 415D +挿 そう 415E +掻 そう 415F +操 そう 4160 +早 そう 4161 +曹 そう 4162 +巣 そう 4163 +槍 そう 4164 +槽 そう 4165 +漕 そう 4166 +燥 そう 4167 +争 そう 4168 +痩 そう 4169 +相 そう 416A +窓 そう 416B +糟 そう 416C +総 そう 416D +綜 そう 416E +聡 そう 416F +草 そう 4170 +荘 そう 4171 +葬 そう 4172 +蒼 そう 4173 +藻 そう 4174 +装 そう 4175 +走 そう 4176 +送 そう 4177 +遭 そう 4178 +鎗 そう 4179 +霜 そう 417A +騒 そう 417B +像 ぞう 417C +増 ぞう 417D +憎 ぞう 417E +臓 ぞう 4221 +蔵 ぞう 4222 +贈 ぞう 4223 +造 ぞう 4224 +促 そく 4225 +側 そく 4226 +則 そく 4227 +即 そく 4228 +息 そく 4229 +捉 そく 422A +束 そく 422B +測 そく 422C +足 そく 422D +速 そく 422E +俗 ぞく 422F +属 ぞく 4230 +賊 ぞく 4231 +族 ぞく 4232 +続 ぞく 4233 +卒 そつ 4234 +袖 そで 4235 +其 それ 4236 +揃 そろえ 4237 +存 そん 4238 +孫 そん 4239 +尊 そん 423A +損 そん 423B +村 そん 423C +遜 そん 423D +他 た 423E +多 た 423F +太 た 4240 +汰 た 4241 +詑 た 4242 +唾 た 4243 +堕 だ 4244 +妥 だ 4245 +惰 だ 4246 +打 だ 4247 +柁 だ 4248 +舵 だ 4249 +楕 だ 424A +陀 だ 424B +駄 だ 424C +騨 だ 424D +体 たい 424E +堆 たい 424F +対 たい 4250 +耐 たい 4251 +岱 たい 4252 +帯 たい 4253 +待 たい 4254 +怠 たい 4255 +態 たい 4256 +戴 たい 4257 +替 たい 4258 +泰 たい 4259 +滞 たい 425A +胎 たい 425B +腿 たい 425C +苔 たい 425D +袋 たい 425E +貸 たい 425F +退 たい 4260 +逮 たい 4261 +隊 たい 4262 +黛 たい 4263 +鯛 たい 4264 +代 たい 4265 +台 たい 4266 +大 たい 4267 +第 だい 4268 +醍 だい 4269 +題 だい 426A +鷹 たか 426B +滝 たき 426C +瀧 たき 426D +卓 たく 426E +啄 たく 426F +宅 たく 4270 +托 たく 4271 +択 たく 4272 +拓 たく 4273 +沢 たく 4274 +濯 たく 4275 +琢 たく 4276 +託 たく 4277 +鐸 たく 4278 +濁 だく 4279 +諾 だく 427A +茸 たけ 427B +凧 たこ 427C +蛸 たこ 427D +只 ただ 427E +叩 たたく 4321 +但 ただし 4322 +達 たつ 4323 +辰 たつ 4324 +奪 だつ 4325 +脱 だつ 4326 +巽 たつみ 4327 +竪 たて 4328 +辿 たどる 4329 +棚 たな 432A +谷 たに 432B +狸 たぬき 432C +鱈 たら 432D +樽 たる 432E +誰 だれ 432F +丹 たん 4330 +単 たん 4331 +嘆 たん 4332 +坦 たん 4333 +担 たん 4334 +探 たん 4335 +旦 たん 4336 +歎 たん 4337 +淡 たん 4338 +湛 たん 4339 +炭 たん 433A +短 たん 433B +端 たん 433C +箪 たん 433D +綻 たん 433E +耽 たん 433F +胆 たん 4340 +蛋 たん 4341 +誕 たん 4342 +鍛 たん 4343 +団 だん 4344 +壇 だん 4345 +弾 だん 4346 +断 だん 4347 +暖 だん 4348 +檀 だん 4349 +段 だん 434A +男 だん 434B +談 だん 434C +値 ち 434D +知 ち 434E +地 ち 434F +弛 ち 4350 +恥 ち 4351 +智 ち 4352 +池 ち 4353 +痴 ち 4354 +稚 ち 4355 +置 ち 4356 +致 ち 4357 +蜘 ち 4358 +遅 ち 4359 +馳 ち 435A +築 ちく 435B +畜 ちく 435C +竹 ちく 435D +筑 ちく 435E +蓄 ちく 435F +逐 ちく 4360 +秩 ちつ 4361 +窒 ちつ 4362 +茶 ちゃ 4363 +嫡 ちゃく 4364 +着 ちゃく 4365 +中 ちゅう 4366 +仲 ちゅう 4367 +宙 ちゅう 4368 +忠 ちゅう 4369 +抽 ちゅう 436A +昼 ちゅう 436B +柱 ちゅう 436C +注 ちゅう 436D +虫 ちゅう 436E +衷 ちゅう 436F +註 ちゅう 4370 +酎 ちゅう 4371 +鋳 ちゅう 4372 +駐 ちゅう 4373 +樗 ちょ 4374 +瀦 ちょ 4375 +猪 ちょ 4376 +苧 ちょ 4377 +著 ちょ 4378 +貯 ちょ 4379 +丁 ちょう 437A +兆 ちょう 437B +凋 ちょう 437C +喋 ちょう 437D +寵 ちょう 437E +帖 ちょう 4421 +帳 ちょう 4422 +庁 ちょう 4423 +弔 ちょう 4424 +張 ちょう 4425 +彫 ちょう 4426 +徴 ちょう 4427 +懲 ちょう 4428 +挑 ちょう 4429 +暢 ちょう 442A +朝 ちょう 442B +潮 ちょう 442C +牒 ちょう 442D +町 ちょう 442E +眺 ちょう 442F +聴 ちょう 4430 +脹 ちょう 4431 +腸 ちょう 4432 +蝶 ちょう 4433 +調 ちょう 4434 +諜 ちょう 4435 +超 ちょう 4436 +跳 ちょう 4437 +銚 ちょう 4438 +長 ちょう 4439 +頂 ちょう 443A +鳥 ちょう 443B +勅 ちょく 443C +捗 ちょく 443D +直 ちょく 443E +朕 ちん 443F +沈 ちん 4440 +珍 ちん 4441 +賃 ちん 4442 +鎮 ちん 4443 +陳 ちん 4444 +津 つ 4445 +墜 つい 4446 +椎 つい 4447 +槌 つい 4448 +追 つい 4449 +鎚 つい 444A +痛 つう 444B +通 つう 444C +塚 つか 444D +栂 つが 444E +掴 つかむ 444F +槻 つき 4450 +佃 つく 4451 +漬 つけ 4452 +柘 つげ 4453 +辻 つじ 4454 +蔦 つた 4455 +綴 つづる 4456 +鍔 つば 4457 +椿 つばき 4458 +潰 つぶす 4459 +坪 つぼ 445A +壷 つぼ 445B +嬬 つま 445C +紬 つむぐ 445D +爪 つめ 445E +吊 つる 445F +釣 つる 4460 +鶴 つる 4461 +亭 てい 4462 +低 てい 4463 +停 てい 4464 +偵 てい 4465 +剃 てい 4466 +貞 てい 4467 +呈 てい 4468 +堤 てい 4469 +定 てい 446A +帝 てい 446B +底 てい 446C +庭 てい 446D +廷 てい 446E +弟 てい 446F +悌 てい 4470 +抵 てい 4471 +挺 てい 4472 +提 てい 4473 +梯 てい 4474 +汀 てい 4475 +碇 てい 4476 +禎 てい 4477 +程 てい 4478 +締 てい 4479 +艇 てい 447A +訂 てい 447B +諦 てい 447C +蹄 てい 447D +逓 てい 447E +邸 てい 4521 +鄭 てい 4522 +釘 てい 4523 +鼎 てい 4524 +泥 でい 4525 +摘 てき 4526 +擢 てき 4527 +敵 てき 4528 +滴 てき 4529 +的 てき 452A +笛 てき 452B +適 てき 452C +鏑 てき 452D +溺 でき 452E +哲 てつ 452F +徹 てつ 4530 +撤 てつ 4531 +轍 てつ 4532 +迭 てつ 4533 +鉄 てつ 4534 +典 てん 4535 +填 てん 4536 +天 てん 4537 +展 てん 4538 +店 てん 4539 +添 てん 453A +纏 てん 453B +甜 てん 453C +貼 てん 453D +転 てん 453E +顛 てん 453F +点 てん 4540 +伝 でん 4541 +殿 でん 4542 +澱 でん 4543 +田 でん 4544 +電 でん 4545 +兎 と 4546 +吐 と 4547 +堵 と 4548 +塗 と 4549 +妬 と 454A +屠 と 454B +徒 と 454C +斗 と 454D +杜 と 454E +渡 と 454F +登 と 4550 +菟 と 4551 +賭 と 4552 +途 と 4553 +都 と 4554 +鍍 と 4555 +砥 と 4556 +砺 と 4557 +努 ど 4558 +度 ど 4559 +土 ど 455A +奴 ど 455B +怒 ど 455C +倒 とう 455D +党 とう 455E +冬 とう 455F +凍 とう 4560 +刀 とう 4561 +唐 とう 4562 +塔 とう 4563 +塘 とう 4564 +套 とう 4565 +宕 とう 4566 +島 とう 4567 +嶋 とう 4568 +悼 とう 4569 +投 とう 456A +搭 とう 456B +東 とう 456C +桃 とう 456D +梼 とう 456E +棟 とう 456F +盗 とう 4570 +淘 とう 4571 +湯 とう 4572 +涛 とう 4573 +灯 とう 4574 +燈 とう 4575 +当 とう 4576 +痘 とう 4577 +祷 とう 4578 +等 とう 4579 +答 とう 457A +筒 とう 457B +糖 とう 457C +統 とう 457D +到 とう 457E +董 とう 4621 +蕩 とう 4622 +藤 とう 4623 +討 とう 4624 +謄 とう 4625 +豆 とう 4626 +踏 とう 4627 +逃 とう 4628 +透 とう 4629 +鐙 とう 462A +陶 とう 462B +頭 とう 462C +騰 とう 462D +闘 とう 462E +働 どう 462F +動 どう 4630 +同 どう 4631 +堂 どう 4632 +導 どう 4633 +憧 どう 4634 +撞 どう 4635 +洞 どう 4636 +瞳 どう 4637 +童 どう 4638 +胴 どう 4639 +萄 どう 463A +道 どう 463B +銅 どう 463C +峠 とうげ 463D +鴇 とき 463E +匿 とく 463F +得 とく 4640 +徳 とく 4641 +涜 とく 4642 +特 とく 4643 +督 とく 4644 +禿 とく 4645 +篤 とく 4646 +毒 どく 4647 +独 どく 4648 +読 どく 4649 +栃 とち 464A +橡 とち 464B +凸 とつ 464C +突 とつ 464D +椴 とど 464E +届 とどく 464F +鳶 とび 4650 +苫 とま 4651 +寅 とら 4652 +酉 とり 4653 +瀞 とろ 4654 +噸 とん 4655 +屯 とん 4656 +惇 とん 4657 +敦 とん 4658 +沌 とん 4659 +豚 とん 465A +遁 とん 465B +頓 とん 465C +呑 どん 465D +曇 どん 465E +鈍 どん 465F +奈 な 4660 +那 な 4661 +内 ない 4662 +乍 ながら 4663 +凪 なぎ 4664 +薙 なぎ 4665 +謎 なぞ 4666 +灘 なだ 4667 +捺 なつ 4668 +鍋 なべ 4669 +楢 なら 466A +馴 なれる 466B +縄 なわ 466C +畷 なわて 466D +南 なん 466E +楠 なん 466F +軟 なん 4670 +難 なん 4671 +汝 なん 4672 +二 に 4673 +尼 に 4674 +弐 に 4675 +迩 に 4676 +匂 におう 4677 +賑 にぎやか 4678 +肉 にく 4679 +虹 にじ 467A +廿 にじゅう 467B +日 にち 467C +乳 にゅう 467D +入 にゅう 467E +如 にょ 4721 +尿 にょう 4722 +韮 にら 4723 +任 にん 4724 +妊 にん 4725 +忍 にん 4726 +認 にん 4727 +濡 ぬれる 4728 +禰 ね 4729 +祢 ね 472A +寧 ね 472B +葱 ねぎ 472C +猫 ねこ 472D +熱 ねつ 472E +年 ねん 472F +念 ねん 4730 +捻 ねん 4731 +撚 ねん 4732 +燃 ねん 4733 +粘 ねん 4734 +乃 の 4735 +廼 の 4736 +之 の 4737 +埜 の 4738 +嚢 のう 4739 +悩 のう 473A +濃 のう 473B +納 のう 473C +能 のう 473D +脳 のう 473E +膿 のう 473F +農 のう 4740 +覗 のぞく 4741 +蚤 のみ 4742 +巴 は 4743 +把 は 4744 +播 は 4745 +覇 は 4746 +杷 は 4747 +波 は 4748 +派 は 4749 +琶 は 474A +破 は 474B +婆 ば 474C +罵 ば 474D +芭 ば 474E +馬 ば 474F +俳 はい 4750 +廃 はい 4751 +拝 はい 4752 +排 はい 4753 +敗 はい 4754 +杯 はい 4755 +盃 はい 4756 +牌 はい 4757 +背 はい 4758 +肺 はい 4759 +輩 はい 475A +配 はい 475B +倍 ばい 475C +培 ばい 475D +媒 ばい 475E +梅 ばい 475F +楳 ばい 4760 +煤 ばい 4761 +狽 ばい 4762 +買 ばい 4763 +売 ばい 4764 +賠 ばい 4765 +陪 ばい 4766 +這 はう 4767 +蝿 はえ 4768 +秤 はかり 4769 +矧 はぎ 476A +萩 はぎ 476B +伯 はく 476C +剥 はく 476D +博 はく 476E +拍 はく 476F +柏 はく 4770 +泊 はく 4771 +白 はく 4772 +箔 はく 4773 +粕 はく 4774 +舶 はく 4775 +薄 はく 4776 +迫 はく 4777 +曝 ばく 4778 +漠 ばく 4779 +爆 ばく 477A +縛 ばく 477B +莫 ばく 477C +駁 ばく 477D +麦 ばく 477E +函 はこ 4821 +箱 はこ 4822 +硲 はざま 4823 +箸 はし 4824 +肇 はじめ 4825 +筈 はず 4826 +櫨 はぜ 4827 +幡 はた 4828 +肌 はだ 4829 +畑 はたけ 482A +畠 はたけ 482B +八 はち 482C +鉢 はち 482D +溌 はつ 482E +発 はつ 482F +醗 はつ 4830 +髪 はつ 4831 +伐 ばつ 4832 +罰 ばつ 4833 +抜 ばつ 4834 +筏 ばつ 4835 +閥 ばつ 4836 +鳩 はと 4837 +噺 はなし 4838 +塙 はなわ 4839 +蛤 はまぐり 483A +隼 はやぶさ 483B +伴 はん 483C +判 はん 483D +半 はん 483E +反 はん 483F +叛 はん 4840 +帆 はん 4841 +搬 はん 4842 +斑 はん 4843 +板 はん 4844 +氾 はん 4845 +汎 はん 4846 +版 はん 4847 +犯 はん 4848 +班 はん 4849 +畔 はん 484A +繁 はん 484B +般 はん 484C +藩 はん 484D +販 はん 484E +範 はん 484F +釆 はん 4850 +煩 はん 4851 +頒 はん 4852 +飯 はん 4853 +挽 ばん 4854 +晩 ばん 4855 +番 ばん 4856 +盤 ばん 4857 +磐 ばん 4858 +蕃 ばん 4859 +蛮 ばん 485A +匪 ひ 485B +卑 ひ 485C +否 ひ 485D +妃 ひ 485E +庇 ひ 485F +彼 ひ 4860 +悲 ひ 4861 +扉 ひ 4862 +批 ひ 4863 +披 ひ 4864 +斐 ひ 4865 +比 ひ 4866 +泌 ひ 4867 +疲 ひ 4868 +皮 ひ 4869 +碑 ひ 486A +秘 ひ 486B +緋 ひ 486C +罷 ひ 486D +肥 ひ 486E +被 ひ 486F +誹 ひ 4870 +費 ひ 4871 +避 ひ 4872 +非 ひ 4873 +飛 ひ 4874 +樋 ひ 4875 +簸 ひ 4876 +備 び 4877 +尾 び 4878 +微 び 4879 +枇 び 487A +毘 び 487B +琵 び 487C +眉 び 487D +美 び 487E +鼻 び 4921 +柊 ひいらぎ 4922 +稗 ひえ 4923 +匹 ひき 4924 +疋 ひき 4925 +髭 ひげ 4926 +彦 ひこ 4927 +膝 ひざ 4928 +菱 ひし 4929 +肘 ひじ 492A +弼 ひつ 492B +必 ひつ 492C +畢 ひつ 492D +筆 ひつ 492E +逼 ひつ 492F +桧 ひのき 4930 +姫 ひめ 4931 +媛 ひめ 4932 +紐 ひも 4933 +百 ひゃく 4934 +謬 びゅう 4935 +俵 ひょう 4936 +彪 ひょう 4937 +標 ひょう 4938 +氷 ひょう 4939 +漂 ひょう 493A +瓢 ひょう 493B +票 ひょう 493C +表 ひょう 493D +評 ひょう 493E +豹 ひょう 493F +廟 びょう 4940 +描 びょう 4941 +病 びょう 4942 +秒 びょう 4943 +苗 びょう 4944 +錨 びょう 4945 +鋲 びょう 4946 +蒜 ひる 4947 +蛭 ひる 4948 +鰭 ひれ 4949 +品 ひん 494A +彬 ひん 494B +斌 ひん 494C +浜 ひん 494D +瀕 ひん 494E +貧 ひん 494F +賓 ひん 4950 +頻 ひん 4951 +敏 びん 4952 +瓶 びん 4953 +不 ふ 4954 +付 ふ 4955 +埠 ふ 4956 +夫 ふ 4957 +婦 ふ 4958 +富 ふ 4959 +冨 ふ 495A +布 ふ 495B +府 ふ 495C +怖 ふ 495D +扶 ふ 495E +敷 ふ 495F +斧 ふ 4960 +普 ふ 4961 +浮 ふ 4962 +父 ふ 4963 +符 ふ 4964 +腐 ふ 4965 +膚 ふ 4966 +芙 ふ 4967 +譜 ふ 4968 +負 ふ 4969 +賦 ふ 496A +赴 ふ 496B +阜 ふ 496C +附 ふ 496D +侮 ぶ 496E +撫 ぶ 496F +武 ぶ 4970 +舞 ぶ 4971 +葡 ぶ 4972 +蕪 ぶ 4973 +部 ぶ 4974 +封 ふう 4975 +楓 ふう 4976 +風 ふう 4977 +葺 ふき 4978 +蕗 ふき 4979 +伏 ふく 497A +副 ふく 497B +復 ふく 497C +幅 ふく 497D +服 ふく 497E +福 ふく 4A21 +腹 ふく 4A22 +複 ふく 4A23 +覆 ふく 4A24 +淵 ふち 4A25 +弗 ふつ 4A26 +払 ふつ 4A27 +沸 ふつ 4A28 +仏 ふつ 4A29 +物 ぶつ 4A2A +鮒 ふな 4A2B +分 ふん 4A2C +吻 ふん 4A2D +噴 ふん 4A2E +墳 ふん 4A2F +憤 ふん 4A30 +扮 ふん 4A31 +焚 ふん 4A32 +奮 ふん 4A33 +粉 ふん 4A34 +糞 ふん 4A35 +紛 ふん 4A36 +雰 ふん 4A37 +文 ぶん 4A38 +聞 ぶん 4A39 +丙 へい 4A3A +併 へい 4A3B +兵 へい 4A3C +塀 へい 4A3D +幣 へい 4A3E +平 へい 4A3F +弊 へい 4A40 +柄 へい 4A41 +並 へい 4A42 +蔽 へい 4A43 +閉 へい 4A44 +陛 へい 4A45 +米 べい 4A46 +頁 ぺーじ 4A47 +僻 へき 4A48 +壁 へき 4A49 +癖 へき 4A4A +碧 へき 4A4B +別 べつ 4A4C +瞥 べつ 4A4D +蔑 べつ 4A4E +箆 へら 4A4F +偏 へん 4A50 +変 へん 4A51 +片 へん 4A52 +篇 へん 4A53 +編 へん 4A54 +辺 へん 4A55 +返 へん 4A56 +遍 へん 4A57 +便 べん 4A58 +勉 べん 4A59 +娩 べん 4A5A +弁 べん 4A5B +鞭 べん 4A5C +保 ほ 4A5D +舗 ほ 4A5E +鋪 ほ 4A5F +圃 ほ 4A60 +捕 ほ 4A61 +歩 ほ 4A62 +甫 ほ 4A63 +補 ほ 4A64 +輔 ほ 4A65 +穂 ほ 4A66 +募 ぼ 4A67 +墓 ぼ 4A68 +慕 ぼ 4A69 +戊 ぼ 4A6A +暮 ぼ 4A6B +母 ぼ 4A6C +簿 ぼ 4A6D +菩 ぼ 4A6E +倣 ほう 4A6F +俸 ほう 4A70 +包 ほう 4A71 +呆 ほう 4A72 +報 ほう 4A73 +奉 ほう 4A74 +宝 ほう 4A75 +峰 ほう 4A76 +峯 ほう 4A77 +崩 ほう 4A78 +庖 ほう 4A79 +抱 ほう 4A7A +捧 ほう 4A7B +放 ほう 4A7C +方 ほう 4A7D +朋 ほう 4A7E +法 ほう 4B21 +泡 ほう 4B22 +烹 ほう 4B23 +砲 ほう 4B24 +縫 ほう 4B25 +胞 ほう 4B26 +芳 ほう 4B27 +萌 ほう 4B28 +蓬 ほう 4B29 +蜂 ほう 4B2A +褒 ほう 4B2B +訪 ほう 4B2C +豊 ほう 4B2D +邦 ほう 4B2E +鋒 ほう 4B2F +飽 ほう 4B30 +鳳 ほう 4B31 +鵬 ほう 4B32 +乏 ぼう 4B33 +亡 ぼう 4B34 +傍 ぼう 4B35 +剖 ぼう 4B36 +坊 ぼう 4B37 +妨 ぼう 4B38 +帽 ぼう 4B39 +忘 ぼう 4B3A +忙 ぼう 4B3B +房 ぼう 4B3C +暴 ぼう 4B3D +望 ぼう 4B3E +某 ぼう 4B3F +棒 ぼう 4B40 +冒 ぼう 4B41 +紡 ぼう 4B42 +肪 ぼう 4B43 +膨 ぼう 4B44 +謀 ぼう 4B45 +貌 ぼう 4B46 +貿 ぼう 4B47 +鉾 ぼう 4B48 +防 ぼう 4B49 +吠 ほえる 4B4A +頬 ほお 4B4B +北 ほく 4B4C +僕 ぼく 4B4D +卜 ぼく 4B4E +墨 ぼく 4B4F +撲 ぼく 4B50 +朴 ぼく 4B51 +牧 ぼく 4B52 +睦 ぼく 4B53 +穆 ぼく 4B54 +釦 ぼたん 4B55 +勃 ぼつ 4B56 +没 ぼつ 4B57 +殆 ぼつ 4B58 +堀 ほる 4B59 +幌 ほろ 4B5A +奔 ほん 4B5B +本 ほん 4B5C +翻 ぼん 4B5D +凡 ぼん 4B5E +盆 ぼん 4B5F +摩 ま 4B60 +磨 ま 4B61 +魔 ま 4B62 +麻 ま 4B63 +埋 まい 4B64 +妹 まい 4B65 +昧 まい 4B66 +枚 まい 4B67 +毎 まい 4B68 +哩 まいる 4B69 +槙 まき 4B6A +幕 まく 4B6B +膜 まく 4B6C +枕 まくら 4B6D +鮪 まぐろ 4B6E +柾 まさ 4B6F +鱒 ます 4B70 +桝 ます 4B71 +亦 また 4B72 +俣 また 4B73 +又 また 4B74 +抹 まつ 4B75 +末 まつ 4B76 +沫 まつ 4B77 +迄 まで 4B78 +侭 まま 4B79 +繭 まゆ 4B7A +麿 まろ 4B7B +万 まん 4B7C +慢 まん 4B7D +満 まん 4B7E +漫 まん 4C21 +蔓 まん 4C22 +味 み 4C23 +未 み 4C24 +魅 み 4C25 +巳 み 4C26 +箕 み 4C27 +岬 みさき 4C28 +密 みつ 4C29 +蜜 みつ 4C2A +湊 みなと 4C2B +蓑 みの 4C2C +稔 みのる 4C2D +脈 みゃく 4C2E +妙 みょう 4C2F +粍 みりめーとる 4C30 +民 みん 4C31 +眠 みん 4C32 +務 む 4C33 +夢 む 4C34 +無 む 4C35 +牟 む 4C36 +矛 む 4C37 +霧 む 4C38 +鵡 む 4C39 +椋 むく 4C3A +婿 むこ 4C3B +娘 むすめ 4C3C +冥 めい 4C3D +名 めい 4C3E +命 めい 4C3F +明 めい 4C40 +盟 めい 4C41 +迷 めい 4C42 +銘 めい 4C43 +鳴 めい 4C44 +姪 めい 4C45 +牝 めす 4C46 +滅 めつ 4C47 +免 めん 4C48 +棉 めん 4C49 +綿 めん 4C4A +緬 めん 4C4B +面 めん 4C4C +麺 めん 4C4D +摸 も 4C4E +模 も 4C4F +茂 も 4C50 +妄 もう 4C51 +孟 もう 4C52 +毛 もう 4C53 +猛 もう 4C54 +盲 もう 4C55 +網 もう 4C56 +耗 もう 4C57 +蒙 もう 4C58 +儲 もうけ 4C59 +木 もく 4C5A +黙 もく 4C5B +目 もく 4C5C +杢 もく 4C5D +勿 もち 4C5E +餅 もち 4C5F +尤 もっとも 4C60 +戻 もどる 4C61 +籾 もみ 4C62 +貰 もらう 4C63 +問 もん 4C64 +悶 もん 4C65 +紋 もん 4C66 +門 もん 4C67 +匁 もんめ 4C68 +也 や 4C69 +冶 や 4C6A +夜 や 4C6B +爺 や 4C6C +耶 や 4C6D +野 や 4C6E +弥 や 4C6F +矢 や 4C70 +厄 やく 4C71 +役 やく 4C72 +約 やく 4C73 +薬 やく 4C74 +訳 やく 4C75 +躍 やく 4C76 +靖 やす 4C77 +柳 やなぎ 4C78 +薮 やぶ 4C79 +鑓 やり 4C7A +愉 ゆ 4C7B +愈 ゆ 4C7C +油 ゆ 4C7D +癒 ゆ 4C7E +諭 ゆ 4D21 +輸 ゆ 4D22 +唯 ゆい 4D23 +佑 ゆう 4D24 +優 ゆう 4D25 +勇 ゆう 4D26 +友 ゆう 4D27 +宥 ゆう 4D28 +幽 ゆう 4D29 +悠 ゆう 4D2A +憂 ゆう 4D2B +揖 ゆう 4D2C +有 ゆう 4D2D +柚 ゆう 4D2E +湧 ゆう 4D2F +涌 ゆう 4D30 +猶 ゆう 4D31 +猷 ゆう 4D32 +由 ゆう 4D33 +祐 ゆう 4D34 +裕 ゆう 4D35 +誘 ゆう 4D36 +遊 ゆう 4D37 +邑 ゆう 4D38 +郵 ゆう 4D39 +雄 ゆう 4D3A +融 ゆう 4D3B +夕 ゆう 4D3C +予 よ 4D3D +余 よ 4D3E +与 よ 4D3F +誉 よ 4D40 +輿 よ 4D41 +預 よ 4D42 +傭 よう 4D43 +幼 よう 4D44 +妖 よう 4D45 +容 よう 4D46 +庸 よう 4D47 +揚 よう 4D48 +揺 よう 4D49 +擁 よう 4D4A +曜 よう 4D4B +楊 よう 4D4C +様 よう 4D4D +洋 よう 4D4E +溶 よう 4D4F +熔 よう 4D50 +用 よう 4D51 +窯 よう 4D52 +羊 よう 4D53 +耀 よう 4D54 +葉 よう 4D55 +蓉 よう 4D56 +要 よう 4D57 +謡 よう 4D58 +踊 よう 4D59 +遥 よう 4D5A +陽 よう 4D5B +養 よう 4D5C +慾 よく 4D5D +抑 よく 4D5E +欲 よく 4D5F +沃 よく 4D60 +浴 よく 4D61 +翌 よく 4D62 +翼 よく 4D63 +淀 よど 4D64 +羅 ら 4D65 +螺 ら 4D66 +裸 ら 4D67 +来 らい 4D68 +莱 らい 4D69 +頼 らい 4D6A +雷 らい 4D6B +洛 らく 4D6C +絡 らく 4D6D +落 らく 4D6E +酪 らく 4D6F +乱 らん 4D70 +卵 らん 4D71 +嵐 らん 4D72 +欄 らん 4D73 +濫 らん 4D74 +藍 らん 4D75 +蘭 らん 4D76 +覧 らん 4D77 +利 り 4D78 +吏 り 4D79 +履 り 4D7A +李 り 4D7B +梨 り 4D7C +理 り 4D7D +璃 り 4D7E +痢 り 4E21 +裏 り 4E22 +裡 り 4E23 +里 り 4E24 +離 り 4E25 +陸 りく 4E26 +律 りつ 4E27 +率 りつ 4E28 +立 りつ 4E29 +葎 りつ 4E2A +掠 りゃく 4E2B +略 りゃく 4E2C +劉 りゅう 4E2D +流 りゅう 4E2E +溜 りゅう 4E2F +琉 りゅう 4E30 +留 りゅう 4E31 +硫 りゅう 4E32 +粒 りゅう 4E33 +隆 りゅう 4E34 +竜 りゅう 4E35 +龍 りゅう 4E36 +侶 りょ 4E37 +慮 りょ 4E38 +旅 りょ 4E39 +虜 りょ 4E3A +了 りょう 4E3B +亮 りょう 4E3C +僚 りょう 4E3D +両 りょう 4E3E +凌 りょう 4E3F +寮 りょう 4E40 +料 りょう 4E41 +梁 りょう 4E42 +涼 りょう 4E43 +猟 りょう 4E44 +療 りょう 4E45 +瞭 りょう 4E46 +稜 りょう 4E47 +糧 りょう 4E48 +良 りょう 4E49 +諒 りょう 4E4A +遼 りょう 4E4B +量 りょう 4E4C +陵 りょう 4E4D +領 りょう 4E4E +力 りょく 4E4F +緑 りょく 4E50 +倫 りん 4E51 +厘 りん 4E52 +林 りん 4E53 +淋 りん 4E54 +燐 りん 4E55 +琳 りん 4E56 +臨 りん 4E57 +輪 りん 4E58 +隣 りん 4E59 +鱗 りん 4E5A +麟 りん 4E5B +瑠 る 4E5C +塁 るい 4E5D +涙 るい 4E5E +累 るい 4E5F +類 るい 4E60 +令 れい 4E61 +伶 れい 4E62 +例 れい 4E63 +冷 れい 4E64 +励 れい 4E65 +嶺 れい 4E66 +怜 れい 4E67 +玲 れい 4E68 +礼 れい 4E69 +苓 れい 4E6A +鈴 れい 4E6B +隷 れい 4E6C +零 れい 4E6D +霊 れい 4E6E +麗 れい 4E6F +齢 れい 4E70 +暦 れき 4E71 +歴 れき 4E72 +列 れつ 4E73 +劣 れつ 4E74 +烈 れつ 4E75 +裂 れつ 4E76 +廉 れん 4E77 +恋 れん 4E78 +憐 れん 4E79 +漣 れん 4E7A +煉 れん 4E7B +簾 れん 4E7C +練 れん 4E7D +聯 れん 4E7E +蓮 れん 4F21 +連 れん 4F22 +錬 れん 4F23 +呂 ろ 4F24 +魯 ろ 4F25 +櫓 ろ 4F26 +炉 ろ 4F27 +賂 ろ 4F28 +路 ろ 4F29 +露 ろ 4F2A +労 ろう 4F2B +婁 ろう 4F2C +廊 ろう 4F2D +弄 ろう 4F2E +朗 ろう 4F2F +楼 ろう 4F30 +榔 ろう 4F31 +浪 ろう 4F32 +漏 ろう 4F33 +牢 ろう 4F34 +狼 ろう 4F35 +篭 ろう 4F36 +老 ろう 4F37 +聾 ろう 4F38 +蝋 ろう 4F39 +郎 ろう 4F3A +六 ろく 4F3B +麓 ろく 4F3C +禄 ろく 4F3D +肋 ろく 4F3E +録 ろく 4F3F +論 ろん 4F40 +倭 わ 4F41 +和 わ 4F42 +話 わ 4F43 +歪 わい 4F44 +賄 わい 4F45 +脇 わき 4F46 +惑 わく 4F47 +枠 わく 4F48 +鷲 わし 4F49 +亙 わたる 4F4A +亘 わたる 4F4B +鰐 わに 4F4C +詫 わび 4F4D +藁 わら 4F4E +蕨 わらび 4F4F +椀 わん 4F50 +湾 わん 4F51 +碗 わん 4F52 +腕 わん 4F53 diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index c88f60a..1ea1efe 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -8,6 +8,16 @@ # MovieGenre.find_or_create_by!(name: genre_name) # end +ApplicationRecord.transaction do + Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' } + Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' } + locale = Locale.find_or_initialize_by(code: 'ain-Kana') + locale.assign_attributes(language_code: 'ain', script_code: 'Kana', region_code: 392, + variants: '', name: 'ain-Kana', deprecated_at: nil) + locale.save! + TagNameCompletion.for_locale!(locale) +end + post_url_sanitisation_rules = [ { priority: 10, source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z', diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index a01a84c..e3641ee 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -24,17 +24,7 @@ FactoryBot.define do 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 + TagNameCompletion.for_tag!(tag) end end end diff --git a/backend/spec/models/language_spec.rb b/backend/spec/models/language_spec.rb index 9a2edcb..9749849 100644 --- a/backend/spec/models/language_spec.rb +++ b/backend/spec/models/language_spec.rb @@ -6,9 +6,8 @@ RSpec.describe Language, type: :model do 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') } + it 'accepts Ainu when multilingual support is enabled' do + expect { described_class.create!(code: 'ain', name: 'ain') } .to change(described_class, :count).by(1) end diff --git a/backend/spec/models/locale_activation_spec.rb b/backend/spec/models/locale_activation_spec.rb new file mode 100644 index 0000000..e7d80d5 --- /dev/null +++ b/backend/spec/models/locale_activation_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe 'Locale activation', type: :model do + let!(:language) { Language.create!(code: 'ain', name: 'ain') } + let!(:script) { Script.create!(code: 'Kana', name: 'Kana') } + let!(:first) { create(:tag, primary_name: 'activation_first') } + let!(:second) { create(:tag, primary_name: 'activation_second') } + + def build_locale deprecated_at: nil + Locale.new(code: 'ain-Kana', language_code: language.code, + script_code: script.code, region_code: 392, variants: '', + name: 'ain-Kana', deprecated_at:) + end + + def generated_names + TagName.where(language_code: 'ain', primary_flg: true) + end + + def fail_second_generation! + allow(TagName).to receive(:generate_name).and_call_original + allow(TagName).to receive(:generate_name) + .with(kind_of(Locale), second, second.tag_name('ja')) + .and_raise('backfill failed') + end + + it 'rolls back a newly created locale and the preceding generated names' do + locale = build_locale + fail_second_generation! + + expect { locale.save! }.to raise_error('backfill failed') + + expect(Locale.exists?(code: 'ain-Kana')).to be(false) + expect(generated_names).to be_empty + expect(first.reload.name('ja')).to eq('activation_first') + expect(second.reload.name('ja')).to eq('activation_second') + end + + it 'does not backfill while the locale is inactive' do + build_locale(deprecated_at: Time.current).save! + + expect(generated_names).to be_empty + end + + it 'backfills missing primary names when the locale becomes active' do + locale = build_locale(deprecated_at: Time.current) + locale.save! + + locale.update!(deprecated_at: nil) + + expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id) + expect(generated_names).to all(have_attributes( + script_code: 'Kana', primary_flg: true, auto_generated: true)) + end + + it 'rolls back activation and all generated names when backfill fails' do + locale = build_locale(deprecated_at: Time.current) + locale.save! + previous_deprecated_at = locale.reload.deprecated_at + fail_second_generation! + + expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed') + + expect(locale.reload.deprecated_at).to eq(previous_deprecated_at) + expect(generated_names).to be_empty + end + + it 'preserves a human primary through deactivation and repeated activation' do + locale = build_locale + locale.save! + primary = generated_names.find_by!(tag: first) + primary.update!(name: 'human_confirmed', auto_generated: false) + snapshot = generated_names.order(:id).map(&:attributes) + + 2.times do + locale.update!(deprecated_at: Time.current) + locale.update!(deprecated_at: nil) + end + + expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot) + expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false) + end +end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb index 62fbd55..9e559a4 100644 --- a/backend/spec/models/locale_spec.rb +++ b/backend/spec/models/locale_spec.rb @@ -1,13 +1,13 @@ require 'rails_helper' RSpec.describe Locale, type: :model do - include_context 'English locale' + include_context 'Ainu locale' - def create_english_locale! - prepare_english_reference! - described_class.create!(code: 'en', language_code: 'en', - script_code: 'Latn', region_code: 826, - variants: '', name: 'English') + def create_ainu_locale! + prepare_ainu_reference! + described_class.create!(code: 'ain-Kana', language_code: 'ain', + script_code: 'Kana', region_code: 392, + variants: '', name: 'Ainu') end it 'keeps the active Nipponese locale consistent with its identity tuple' do @@ -27,7 +27,6 @@ RSpec.describe Locale, type: :model do 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 @@ -35,63 +34,60 @@ 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_english_locale! + locale = create_ainu_locale! expect(TagName).to have_received(:generate_name) .with(locale, first, primary_tag_name_for(first, 'ja')) expect(TagName).to have_received(:generate_name) .with(locale, second, primary_tag_name_for(second, 'ja')) - expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true)) + expect(first.tag_names.find_by!(language_code: 'ain', primary_flg: true)) .to have_attributes(name: 'name_from_generator', tag_id: first.id, - language_code: 'en', script_code: 'Latn', + language_code: 'ain', script_code: 'Kana', primary_flg: true, auto_generated: true) - expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true)) - .to have_attributes(tag_id: second.id, language_code: 'en', - script_code: 'Latn', primary_flg: true, + expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true)) + .to have_attributes(tag_id: second.id, language_code: 'ain', + script_code: 'Kana', primary_flg: true, auto_generated: true) - expect(TagName.where(language_code: 'en', primary_flg: true, + expect(TagName.where(language_code: 'ain', primary_flg: true, tag_id: [first.id, second.id]).count).to eq(2) end it 'does not duplicate an existing primary name in the new language' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_reference! + prepare_ainu_reference! tag = create(:tag, primary_name: 'already_named') - existing = create(:tag_name, name: 'existing_english', tag:, - language_code: 'en', script_code: 'Latn') + existing = create(:tag_name, name: 'existing_ainu', tag:, + language_code: 'ain', script_code: 'Kana') - create_english_locale! + create_ainu_locale! - expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true)) + expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true)) .to contain_exactly(existing) expect(existing.reload).to have_attributes( - tag_id: tag.id, language_code: 'en', script_code: 'Latn', + tag_id: tag.id, language_code: 'ain', script_code: 'Kana', primary_flg: true, auto_generated: false) end it 'creates a primary name when the language has only an alias' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_reference! + prepare_ainu_reference! tag = create(:tag, primary_name: 'alias_only_tag') alias_name = create(:tag_name, :alias, name: 'alias_en', tag:, - language_code: 'en', script_code: 'Latn') + language_code: 'ain', script_code: 'Kana') - create_english_locale! + create_ainu_locale! expect(alias_name.reload).to have_attributes( tag_id: tag.id, - language_code: 'en', script_code: 'Latn', primary_flg: false) - generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) - expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en', - script_code: 'Latn', primary_flg: true, + language_code: 'ain', script_code: 'Kana', primary_flg: false) + generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true) + expect(generated).to have_attributes(tag_id: tag.id, language_code: 'ain', + script_code: 'Kana', primary_flg: true, auto_generated: true) - expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1) + expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1) end it 'rolls back the locale and all generated names when backfill fails' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_reference! + prepare_ainu_reference! first = create(:tag, primary_name: 'atomic_first') second = create(:tag, primary_name: 'atomic_second') allow(TagName).to receive(:generate_name).and_call_original @@ -99,31 +95,27 @@ RSpec.describe Locale, type: :model do .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 { create_ainu_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 + expect(described_class.exists?(code: 'ain-Kana')).to be(false) + expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty end - it 'accepts a stable locale code outside the historical enum values' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_reference! + it 'rejects a private locale code outside the supported locale set' do + prepare_ainu_reference! - locale = described_class.create!( - code: 'en-x-rspec', language_code: 'en', - script_code: 'Latn', region_code: 826, - variants: 'x-rspec', name: 'English') - - expect(locale.reload).to have_attributes( - code: 'en-x-rspec', language_code: 'en', script_code: 'Latn', - region_code: 826, variants: 'x-rspec') + expect { + described_class.create!( + code: 'ain-Kana-x-rspec', language_code: 'ain', + script_code: 'Kana', region_code: 392, + variants: 'x-rspec', name: 'Ainu') + }.to raise_error(ArgumentError) end - it 'registers the future British English locale tuple' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'registers the supported Ainu locale tuple' do - expect(locale).to have_attributes(code: 'en', language_code: 'en', - script_code: 'Latn', region_code: 826, + expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain', + script_code: 'Kana', region_code: 392, variants: '') end end diff --git a/backend/spec/models/script_spec.rb b/backend/spec/models/script_spec.rb index 20365c1..075d105 100644 --- a/backend/spec/models/script_spec.rb +++ b/backend/spec/models/script_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe Script, type: :model do - include_context 'English locale' + include_context 'Ainu locale' it 'selects locales by script code, not language code' do expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese) @@ -25,11 +25,10 @@ RSpec.describe Script, type: :model do } end - it 'selects Latin locales when multilingual support is enabled' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'selects Kana locales when multilingual support is enabled' do locale - expect(described_class.find('Latn').locales).to contain_exactly(locale) + expect(described_class.find('Kana').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 58bd168..3d88b96 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -95,38 +95,36 @@ RSpec.describe TagNameSanitisationRule, type: :model do end context 'when only another language has the sanitised name' do - include_context 'English locale' + include_context 'Ainu locale' it 'keeps both names when sanitisation produces a cross-language match' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale nipponese = create(:tag_name, name: 'foobar') - english = create(:tag_name, name: 'temporary', - language_code: 'en', script_code: 'Latn') - english.update_columns(name: 'foo_bar') + ainu = create(:tag_name, name: 'temporary', + language_code: 'ain', script_code: 'Kana') + ainu.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(ainu.reload).to have_attributes(name: 'foobar', language_code: 'ain') expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja') end it 'renames without merging tags or deleting either language identity' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale nipponese = create(:tag, primary_name: 'foobar') - english_name = create(:tag_name, name: 'temporary', - language_code: 'en', script_code: 'Latn') - english = create(:tag, primary_tag_name: english_name) - english_name.update_columns(name: 'foo_bar') + ainu_name = create(:tag_name, name: 'temporary', + language_code: 'ain', script_code: 'Kana') + ainu = create(:tag, primary_tag_name: ainu_name) + ainu_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(ainu_name.reload).to have_attributes( + name: 'foobar', language_code: 'ain', tag_id: ainu.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) + expect(primary_tag_name_for(ainu.reload, 'ain')).to eq(ainu_name) end end diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 3d8ff38..848bc5e 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -1,17 +1,16 @@ require 'rails_helper' RSpec.describe TagName, type: :model do - include_context 'English locale' + include_context 'Ainu 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') + ainu = build(:tag_name, name: 'shared_name', + language_code: 'ain', script_code: 'Kana') - expect(english).to be_valid - expect { english.save! }.to change(described_class, :count).by(1) + expect(ainu).to be_valid + expect { ainu.save! }.to change(described_class, :count).by(1) end it 'rejects a duplicate name within the same language' do @@ -67,11 +66,10 @@ RSpec.describe TagName, type: :model do 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)) + expect(TagName.where(tag:, language_code: 'ain', primary_flg: true)) .to contain_exactly(have_attributes(auto_generated: true)) end @@ -114,46 +112,44 @@ 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) - english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', - language_code: 'en', script_code: 'Latn') + ainu_primary = primary_tag_name_for(tag, 'ain') + ainu_primary.update!(name: 'ainu_primary', auto_generated: false) + ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias', + language_code: 'ain', script_code: 'Kana') representative_id = tag.tag_name('ja')&.id - primary = described_class.find_by!(tag_id: english_alias.tag_id, - language_code: english_alias.language_code, + primary = described_class.find_by!(tag_id: ainu_alias.tag_id, + language_code: ainu_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(primary).to eq(ainu_primary) + expect(described_class.canonicalise(locale, [ainu_alias.name])) + .to eq([ainu_primary.name]) expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) end it 'resolves only aliases in the requested language' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale nipponese = create(:tag, primary_name: 'nipponese_canonical') - english_name = create(:tag_name, name: 'english_canonical', - language_code: 'en', script_code: 'Latn') - english = create(:tag, primary_tag_name: english_name) + ainu_name = create(:tag_name, name: 'ainu_canonical', + language_code: 'ain', script_code: 'Kana') + ainu = create(:tag, primary_tag_name: ainu_name) nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: nipponese) - english_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: english, - language_code: 'en', script_code: 'Latn') + ainu_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: ainu, + language_code: 'ain', script_code: 'Kana') expect(nipponese_alias.reload).to have_attributes( tag_id: nipponese.id, primary_flg: false, language_code: 'ja') - expect(english_alias.reload).to have_attributes( - tag_id: english.id, - primary_flg: false, language_code: 'en') + expect(ainu_alias.reload).to have_attributes( + tag_id: ainu.id, + primary_flg: false, language_code: 'ain') expect(described_class.canonicalise(locale, ['shared_alias'])) - .to eq(['english_canonical']) + .to eq(['ainu_canonical']) expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) .to eq(['nipponese_canonical']) end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 2bff7fa..5645680 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Tag, type: :model do it 'checks the requested language and treats an empty description as present' do TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) expect(tag.has_description?(language_code: 'ja')).to be(true) - expect(tag.has_description?(language_code: 'en')).to be(false) + expect(tag.has_description?(language_code: 'ain')).to be(false) end it 'returns false without a description' do @@ -23,16 +23,16 @@ RSpec.describe Tag, type: :model do subscriber = ->(*args) { queries << args.last.fetch(:sql) } ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do expect(loaded.has_description?(language_code: 'ja')).to be(true) - expect(loaded.has_description?(language_code: 'en')).to be(false) + expect(loaded.has_description?(language_code: 'ain')).to be(false) end expect(queries).to be_empty end it 'keeps language selection generic without enabling another Language record' do tag.association(:tag_descriptions).target = [ - TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)] + TagDescription.new(tag:, language_code: 'ain', body: '', version_no: 1)] tag.association(:tag_descriptions).loaded! - expect(tag.has_description?(language_code: 'en')).to be(true) + expect(tag.has_description?(language_code: 'ain')).to be(true) expect(tag.has_description?(language_code: 'ja')).to be(false) end end @@ -61,33 +61,32 @@ RSpec.describe Tag, type: :model do end describe '.normalise_tags!' do - include_context 'English locale' + include_context 'Ainu locale' it 'canonicalises aliases in the supplied language only' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_reference! + prepare_ainu_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 + ainu_name = create(:tag_name, name: 'ainu_canonical', + language_code: 'ain', script_code: 'Kana') + ainu = create(:tag, primary_tag_name: ainu_name) + primary_tag_name_for(ainu, 'ja').update!( + name: 'ainu_in_nipponese', auto_generated: false) + ainu_locale = locale nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: nipponese) - english_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: english, - language_code: 'en', script_code: 'Latn') + ainu_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: ainu, + language_code: 'ain', script_code: 'Kana') expect(nipponese_alias.reload).to have_attributes( tag_id: nipponese.id, primary_flg: false, language_code: 'ja') - expect(english_alias.reload).to have_attributes( - tag_id: english.id, - primary_flg: false, language_code: 'en') + expect(ainu_alias.reload).to have_attributes( + tag_id: ainu.id, + primary_flg: false, language_code: 'ain') expect(described_class.normalise_tags!( - english_locale, ['shared_alias'], - with_tagme: false, with_no_deerjikist: false)).to eq([english]) + ainu_locale, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([ainu]) expect(described_class.normalise_tags!( Locale.nipponese, ['shared_alias'], with_tagme: false, with_no_deerjikist: false)).to eq([nipponese]) @@ -253,129 +252,120 @@ RSpec.describe Tag, type: :model do describe '.find_or_create_by_tag_name!' do context 'with an explicit locale' do - include_context 'English locale' + include_context 'Ainu 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) + locale, 'ainu_name', category: :character) - expect(primary_tag_name_for(tag, 'en')).to have_attributes( - language_code: 'en', script_code: 'Latn', + expect(primary_tag_name_for(tag, 'ain')).to have_attributes( + language_code: 'ain', script_code: 'Kana', primary_flg: true, tag_id: tag.id) expect(tag.category).to eq('character') end - it 'uses the English primary as the source for a generated Nipponese name' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'uses a Japanese fallback for an Ainu-origin tag without reverse transcription' do locale - allow(TagName).to receive(:generate_name).and_return('generated_nipponese') + allow(TagName).to receive(:generate_name).and_call_original tag = described_class.find_or_create_by_tag_name!( - locale, 'english_origin', category: :general) + locale, 'ainu_origin', category: :general) - expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) + expect(TagName.where(tag:, language_code: 'ain', primary_flg: true)) .to contain_exactly(have_attributes( - name: 'english_origin', auto_generated: false)) + name: 'ainu_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) + name: "Tag_##{ tag.id }", auto_generated: true)) expect(TagName).to have_received(:generate_name) - .with(Locale.nipponese, tag, source_tag_name) + .with(Locale.nipponese, tag, nil) end it 'gives a new tag one primary name for every active language' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale tag = described_class.find_or_create_by_tag_name!( Locale.nipponese, 'new_multilingual_tag', category: :general) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) - expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) + expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1) end - it 'creates one primary per language when multiple locales use English' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'creates one primary per language when multiple locales use Ainu' do locale described_class.find_or_create_by_tag_name!( - locale, 'before_second_english_locale', category: :general) - Locale.create!(code: 'en-x-rspec', language_code: 'en', - script_code: 'Latn', region_code: 826, - variants: 'x-rspec', name: 'English') + locale, 'before_second_ainu_locale', category: :general) tag = described_class.find_or_create_by_tag_name!( - locale, 'one_english_primary', category: :general) + locale, 'one_ainu_primary', category: :general) + TagNameCompletion.for_tag!(tag, locales: [locale, locale.dup, Locale.nipponese]) - expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1) + expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) end it 'rolls back a new tag and requested name if name generation fails' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale original_tag_count = described_class.count allow(TagName).to receive(:generate_name).and_raise('generation failed') expect { described_class.find_or_create_by_tag_name!( - locale, 'atomic_english_name', category: :general) + locale, 'atomic_ainu_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(TagName.where(language_code: 'ain', name: 'atomic_ainu_name')).to be_empty expect(described_class.joins(:tag_names) - .where(tag_names: { name: 'atomic_english_name' })).to be_empty + .where(tag_names: { name: 'atomic_ainu_name' })).to be_empty 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!( + primary_tag_name_for(nipponese, 'ain').update!( + name: 'other_ainu_name', auto_generated: false) + ainu = described_class.find_or_create_by_tag_name!( locale, 'same_name', category: :character) - expect(english).not_to eq(nipponese) - expect(primary_tag_name_for(english, 'en')).to have_attributes( - name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true) + expect(ainu).not_to eq(nipponese) + expect(primary_tag_name_for(ainu, 'ain')).to have_attributes( + name: 'same_name', tag_id: ainu.id, language_code: 'ain', 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) + locale, 'same_name', category: :general)).to eq(ainu) expect(described_class.find_or_create_by_tag_name!( Locale.nipponese, 'same_name', category: :general)).to eq(nipponese) end it 'preserves the V1 representative when looking up another primary language' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale tag = create(:tag, primary_name: '日本語代表名') representative_id = tag.tag_name('ja')&.id - english = primary_tag_name_for(tag, 'en') - english.update!(name: 'english_primary', auto_generated: false) + ainu = primary_tag_name_for(tag, 'ain') + ainu.update!(name: 'ainu_primary', auto_generated: false) found = described_class.find_or_create_by_tag_name!( - locale, english.name, category: :general) + locale, ainu.name, category: :general) expect(found).to eq(tag) expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) - expect(english.reload.tag_id).to eq(tag.id) + expect(ainu.reload.tag_id).to eq(tag.id) end it 'normalises names using the supplied locale' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale tags = described_class.normalise_tags!( - locale, ['character:normalised_english'], + locale, ['character:normalised_ainu'], with_tagme: false, with_no_deerjikist: false) expect(tags.length).to eq(1) - expect(primary_tag_name_for(tags.first, 'en')).to have_attributes( - name: 'normalised_english', language_code: 'en', script_code: 'Latn', + expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes( + name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana', primary_flg: true, tag_id: tags.first.id) end end @@ -444,26 +434,25 @@ RSpec.describe Tag, type: :model do end context 'with names in multiple languages' do - include_context 'English locale' + include_context 'Ainu 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') + ainu_primary = primary_tag_name_for(target_tag, 'ain') + source_primary = primary_tag_name_for(source_tag, 'ain') source_alias = create(:tag_name, :alias, tag: source_tag, - name: 'source_english_alias', language_code: 'en', script_code: 'Latn') + name: 'source_ainu_alias', language_code: 'ain', script_code: 'Kana') - expect([english_primary, source_primary]).to all(have_attributes( + expect([ainu_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(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary) + expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true)) + .to contain_exactly(ainu_primary) expect([source_primary.reload, source_alias.reload]).to all(have_attributes( - tag_id: target_tag.id, language_code: 'en', primary_flg: false)) + tag_id: target_tag.id, language_code: 'ain', 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( diff --git a/backend/spec/requests/locale_resolution_spec.rb b/backend/spec/requests/locale_resolution_spec.rb new file mode 100644 index 0000000..c125f08 --- /dev/null +++ b/backend/spec/requests/locale_resolution_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe 'API locale resolution', type: :request do + let!(:tag) { create(:tag, primary_name: '日本語正本') } + + it 'defaults an omitted locale to Japanese' do + get "/tags/#{ tag.id }" + + expect(response).to have_http_status(:ok) + expect(json.fetch('name')).to eq('日本語正本') + end + + it 'accepts an explicitly requested active Japanese locale' do + get "/tags/#{ tag.id }", params: { locale: 'ja' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('name')).to eq('日本語正本') + end + + ['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code| + it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do + get "/tags/#{ tag.id }", params: { locale: code } + + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include('日本語正本') + end + end + + it 'rejects a supported locale that has not been registered' do + get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' } + + expect(response).to have_http_status(:not_found) + end + + it 'rejects an inactive locale' do + Locale.nipponese.update!(deprecated_at: Time.current) + + get "/tags/#{ tag.id }", params: { locale: 'ja' } + + expect(response).to have_http_status(:not_found) + end + + it 'does not write posts or tag names under an invalid locale' do + sign_in_as(create_member_user!) + + expect do + post '/posts', params: { + locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale', + tags: 'unwritten_tag' } + end.to change(Post, :count).by(0).and change(TagName, :count).by(0) + + expect(response).to have_http_status(:not_found) + end +end diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb index 48f2ed1..c5ecfcc 100644 --- a/backend/spec/requests/locale_write_paths_spec.rb +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe 'Locale propagation on write paths', type: :request do - include_context 'English locale' + include_context 'Ainu locale' let(:member) { create(:user, :member) } @@ -54,13 +54,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end end - ['en', nil].each do |requested_locale| + ['ain-Kana', 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', @@ -72,7 +71,6 @@ 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) @@ -87,7 +85,6 @@ 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) @@ -102,7 +99,6 @@ 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') @@ -113,7 +109,6 @@ 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') @@ -127,7 +122,6 @@ 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!( @@ -141,7 +135,6 @@ 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) @@ -157,10 +150,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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') + let(:ainu_name) do + create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana') end - let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) } + let(:ainu_tag) { create(:tag, primary_tag_name: ainu_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. @@ -168,173 +161,163 @@ RSpec.describe 'Locale propagation on write paths', type: :request do end end - it 'plans and creates the post using the English identity and category' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'plans and creates the post using the Ainu identity and category' do locale - english_tag + ainu_tag nipponese_tag post '/posts', params: { - locale: 'en', title: 'English identity', - url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' } + locale: 'ain-Kana', title: 'Ainu identity', + url: 'https://example.com/ainu-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).to include(ainu_tag) expect(record.tags).not_to include(nipponese_tag) - expect(english_tag.reload.category).to eq('material') + expect(ainu_tag.reload.category).to eq('material') expect(nipponese_tag.reload.category).to eq('general') end - it 'updates the post using the English identity' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'updates the post using the Ainu identity' do locale - english_tag + ainu_tag nipponese_tag record = create(:post) PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) put "/posts/#{ record.id }", params: { - locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '', + locale: 'ain-Kana', 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.reload.tags).to include(ainu_tag) expect(record.tags).not_to include(nipponese_tag) end - it 'resolves the English material tag' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'resolves the Ainu material tag' do locale - english_tag + ainu_tag nipponese_tag post '/materials', params: { - locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' } + locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' } expect(response).to have_http_status(:created), response.body - expect(Material.find(json.fetch('id')).tag).to eq(english_tag) + expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag) expect(Material.where(tag: nipponese_tag)).to be_empty end - it 'resolves the English parent tag' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'resolves the Ainu parent tag' do locale - english_tag + ainu_tag nipponese_tag child = create(:tag) put "/tags/#{ child.id }", params: { - locale: 'en', name: primary_tag_name_for(child, 'ja').name, + locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name, category: child.category, deprecated: false, aliases: '', parent_tags: 'shared_name' } expect(response).to have_http_status(:ok), response.body - expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id]) + expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id]) end - it 'resolves the English internal tag for a Nico link' do - pending '日本語以外の Locale 有効化は別課題で実装する' + it 'resolves the Ainu internal tag for a Nico link' do locale - english_tag + ainu_tag nipponese_tag external = create(:external_tag) NicoTagVersionRecorder.record!( external_tag: external, event_type: :create, created_by_user: member) - put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' } + put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' } expect(response).to have_http_status(:ok), response.body - expect(external.reload.linked_tags).to contain_exactly(english_tag) + expect(external.reload.linked_tags).to contain_exactly(ainu_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') + context 'when renaming or updating aliases in Ainu' do + let(:ainu_name) do + create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana') end - let(:tag) { create(:tag, primary_tag_name: english_name) } + let(:tag) { create(:tag, primary_tag_name: ainu_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') + generated_name = primary_tag_name_for(generated_tag, 'ain') expect(generated_name.auto_generated).to be(true) patch "/tags/#{ generated_tag.id }", params: { - locale: 'en', name: 'human_english_name' } + locale: 'ain-Kana', name: 'human_ainu_name' } expect(response).to have_http_status(:ok), response.body expect(generated_name.reload).to have_attributes( - name: 'human_english_name', auto_generated: false) + name: 'human_ainu_name', auto_generated: false) end it 'does not steal another tag primary for an alias' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale other_tag = create(:tag, primary_name: 'other_nipponese') - other_primary = primary_tag_name_for(other_tag, 'en') - other_primary.update!(name: 'other_english_primary', auto_generated: false) - create(:tag_name, :alias, tag:, name: 'existing_english_alias', - language_code: 'en', script_code: 'Latn') - primary_name_before = primary_tag_name_for(tag, 'en').name + other_primary = primary_tag_name_for(other_tag, 'ain') + other_primary.update!(name: 'other_ainu_primary', auto_generated: false) + create(:tag_name, :alias, tag:, name: 'existing_ainu_alias', + language_code: 'ain', script_code: 'Kana') + primary_name_before = primary_tag_name_for(tag, 'ain').name aliases_before = - tag.tag_names.where(language_code: 'en', primary_flg: false) + tag.tag_names.where(language_code: 'ain', primary_flg: false) .order(:name).pluck(:name) category_before = tag.category put "/tags/#{ tag.id }", params: { - locale: 'en', name: 'renamed_before_alias_collision', + locale: 'ain-Kana', name: 'renamed_before_alias_collision', category: 'character', deprecated: false, - aliases: 'other_english_primary', parent_tags: '' } + aliases: 'other_ainu_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)) + tag_id: other_tag.id, language_code: 'ain', primary_flg: true) + expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true)) .to contain_exactly(other_primary) - expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before) + expect(primary_tag_name_for(tag.reload, 'ain').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) + tag.tag_names.where(language_code: 'ain', primary_flg: false) .order(:name).pluck(:name) expect(aliases_after).to eq(aliases_before) end end it 'allows a rename to a name already used in Nipponese' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale nipponese = create(:tag, primary_name: 'rename_target') - patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' } + patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', 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(tag.reload, 'ain')).to have_attributes( + name: 'rename_target', language_code: 'ain', tag_id: tag.id) expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes( name: 'rename_target', language_code: 'ja', tag_id: nipponese.id) end it 'creates aliases in the requested language' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale put "/tags/#{ tag.id }", params: { - locale: 'en', name: english_name.name, + locale: 'ain-Kana', name: ainu_name.name, category: tag.category, deprecated: false, - aliases: 'english_alias', parent_tags: '' } + aliases: 'ainu_alias', parent_tags: '' } expect(response).to have_http_status(:ok), response.body - expect(TagName.find_by!(language_code: 'en', name: 'english_alias')) + expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias')) .to have_attributes( - script_code: 'Latn', - tag_id: tag.id, primary_flg: false, language_code: 'en') - primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true) - expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn') + script_code: 'Kana', + tag_id: tag.id, primary_flg: false, language_code: 'ain') + primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true) + expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana') 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', @@ -344,7 +327,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do primary_flg: false, language_code: 'ja') put "/tags/#{ tag.id }", params: { - locale: 'en', name: english_name.name, + locale: 'ain-Kana', name: ainu_name.name, category: tag.category, deprecated: false, aliases: 'shared_alias', parent_tags: '' } @@ -352,12 +335,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do 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')) + expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias')) .to have_attributes( - tag_id: tag.id, script_code: 'Latn', - primary_flg: false, language_code: 'en') + tag_id: tag.id, script_code: 'Kana', + primary_flg: false, language_code: 'ain') expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id)) - .to contain_exactly(['ja', nipponese.id], ['en', tag.id]) + .to contain_exactly(['ja', nipponese.id], ['ain', tag.id]) end end end diff --git a/backend/spec/requests/tag_group_locale_spec.rb b/backend/spec/requests/tag_group_locale_spec.rb index bc3088e..9691b58 100644 --- a/backend/spec/requests/tag_group_locale_spec.rb +++ b/backend/spec/requests/tag_group_locale_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do it 'uses selected-language names in native collection and nested placements' do ["/tag_groups/#{ group.id }", '/tag_groups'].each do |path| - get path, params: { locale: 'ain' } + get path, params: { locale: 'ain-Kana' } expect(response).to have_http_status(:ok) representation = json.is_a?(Array) ? json.sole : json expect(representation.fetch('members').sole.fetch('tag').fetch('name')) @@ -37,14 +37,14 @@ RSpec.describe 'Tag group locale contracts', type: :request do it 'uses snapshot names recursively for history and current names for the latest view' do version = group.versions.sole ainu_name.update!(name: 'renamed_ainu') - get "/wiki/#{ group.id }", params: { locale: 'ain' } + get "/wiki/#{ group.id }", params: { locale: 'ain-Kana' } expect(response).to have_http_status(:ok) expect(json.fetch('body')).to include('renamed_ainu') - get "/wiki/#{ group.id }", params: { locale: 'ain', version: version.id } + get "/wiki/#{ group.id }", params: { locale: 'ain-Kana', version: version.id } expect(response).to have_http_status(:ok) expect(json.fetch('body').scan('ainu_member').length).to eq(2) expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member') - get "/wiki/#{ group.id }/diff", params: { locale: 'ain', from: version.id } + get "/wiki/#{ group.id }/diff", params: { locale: 'ain-Kana', from: version.id } expect(response).to have_http_status(:ok) expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context']) end @@ -52,23 +52,24 @@ RSpec.describe 'Tag group locale contracts', type: :request do ['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix| it "round trips #{ prefix } through create, lookup, search and update" do post '/wiki', params: { - locale: 'ain', title: "#{ prefix }parent", body: '- tag_group:locale group' }, + locale: 'ain-Kana', title: "#{ prefix }parent", body: '- tag_group:locale group' }, headers: headers expect(response).to have_http_status(:created) id = json.fetch('id') expect(json.fetch('body')).to include('タググループ:locale group') path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }" - get path, params: { locale: 'ain' } + get path, params: { locale: 'ain-Kana' } expect(response).to have_http_status(:ok) expect(json.fetch('id')).to eq(id) - get "#{ path }/exists", params: { locale: 'ain' } + get "#{ path }/exists", params: { locale: 'ain-Kana' } expect(response).to have_http_status(:no_content) - get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" } + get '/wiki/search', params: { locale: 'ain-Kana', title: "#{ prefix }parent" } expect(json.map { _1.fetch('id') }).to eq([id]) put "/wiki/#{ id }", params: { - locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers + locale: 'ain-Kana', title: "#{ prefix }renamed", body: '- ainu_member' }, + headers: headers expect(response).to have_http_status(:ok) - get "/tag_groups/#{ id }", params: { locale: 'ain' } + get "/tag_groups/#{ id }", params: { locale: 'ain-Kana' } expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') end end @@ -81,7 +82,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do end.to raise_error(TagGroupMarkdownParser::InvalidDocument) before_state = group_state put "/wiki/#{ group.id }", params: { - locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' }, + locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' }, headers: headers expect(response).to have_http_status(:unprocessable_entity) expect(group_state).to eq(before_state) @@ -89,15 +90,15 @@ RSpec.describe 'Tag group locale contracts', type: :request do it 'passes locale into native validation and facade compatibility checks' do post '/tag_groups', params: { - locale: 'ain', name: 'native', description: nil, members:, placements: }, + locale: 'ain-Kana', name: 'native', description: nil, members:, placements: }, headers:, as: :json expect(response).to have_http_status(:created) id = json.fetch('id') expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') - get "/wiki/#{ id }", params: { locale: 'ain' } + get "/wiki/#{ id }", params: { locale: 'ain-Kana' } expect(response).to have_http_status(:ok) body = json.fetch('body') - put "/wiki/#{ id }", params: { locale: 'ain', title: 'tag_group:native', body: }, + put "/wiki/#{ id }", params: { locale: 'ain-Kana', title: 'tag_group:native', body: }, headers: headers expect(response).to have_http_status(:ok) expect(TagGroup.find(id).version_no).to eq(1) @@ -115,7 +116,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do representation = TagGroupRepr.historical(ainu_locale, version) expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil - get "/wiki/#{ old_group.id }", params: { locale: 'ain', version: version.id } + get "/wiki/#{ old_group.id }", params: { locale: 'ain-Kana', version: version.id } expect(response).to have_http_status(:unprocessable_entity) expect(response.body).not_to include('historical_japanese', 'current_ainu') end diff --git a/backend/spec/requests/wiki_locale_spec.rb b/backend/spec/requests/wiki_locale_spec.rb index f8a653b..9e8998d 100644 --- a/backend/spec/requests/wiki_locale_spec.rb +++ b/backend/spec/requests/wiki_locale_spec.rb @@ -20,12 +20,12 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do ['/wiki', '/wiki/search'].each do |path| it "filters #{ path } by the selected language" do - get path, params: { locale: 'ain' } + get path, params: { locale: 'ain-Kana' } expect(response).to have_http_status(:ok) expect(json.map { _1.fetch('id') }).to eq([ainu.id]) expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body') - get path, params: { locale: 'ain', title: 'japanese' } + get path, params: { locale: 'ain-Kana', title: 'japanese' } expect(response).to have_http_status(:ok) expect(json).to eq([]) end @@ -39,17 +39,17 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do it 'resolves titles and aliases only in the selected language in both APIs' do ['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path| - get path, params: { locale: 'ain' } + get path, params: { locale: 'ain-Kana' } expect(response).to have_http_status(:not_found) end ['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path| - get path, params: { locale: 'ain' } + get path, params: { locale: 'ain-Kana' } expect(response).to have_http_status(:ok) expect(json.fetch('body')).to eq('Ainu body') end - get '/wiki/title/ainu_title/exists', params: { locale: 'ain' } + get '/wiki/title/ainu_title/exists', params: { locale: 'ain-Kana' } expect(response).to have_http_status(:no_content) - get '/wiki/title/japanese_title/exists', params: { locale: 'ain' } + get '/wiki/title/japanese_title/exists', params: { locale: 'ain-Kana' } expect(response).to have_http_status(:not_found) end @@ -57,14 +57,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do revision = current_description_version(japanese) ["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists", "/wiki/#{ japanese.id }/diff"].each do |path| - get path, params: { locale: 'ain', from: revision.id } + get path, params: { locale: 'ain-Kana', from: revision.id } expect(response).to have_http_status(:not_found) expect(response.body).not_to include('Japanese body') end - get '/wiki/changes', params: { locale: 'ain', id: japanese.id } + get '/wiki/changes', params: { locale: 'ain-Kana', id: japanese.id } expect(response).to have_http_status(:ok) expect(json).to eq([]) - get '/wiki/changes', params: { locale: 'ain' } + get '/wiki/changes', params: { locale: 'ain-Kana' } expect(json.map { _1.fetch('revision_id') }) .to eq([current_description_version(ainu).id]) end @@ -73,7 +73,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes] expect do put "/wiki/#{ japanese.id }", params: { - locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers + locale: 'ain-Kana', title: 'renamed', body: 'wrong target' }, headers: headers end.not_to change(TagDescriptionVersion, :count) expect(response).to have_http_status(:not_found) expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes]) @@ -89,10 +89,10 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do snapshot = original.attributes expect do if api == :wiki - post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' }, + post '/wiki', params: { locale: 'ain-Kana', title: 'other_ainu', body: 'created' }, headers: headers else - put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' }, + put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' }, headers: headers end end.to change(TagDescription, :count).by(1) @@ -108,20 +108,20 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do it 'shares selected-language edits and revision identity across both APIs' do snapshot = japanese.attributes first = current_description_version(ainu) - put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' }, + put "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana', body: 'native edit' }, headers: headers expect(response).to have_http_status(:ok) second = current_description_version(ainu) expect(second).to have_attributes(language_code: 'ain', version_no: 2) expect(second.id).not_to eq(first.id) - get "/wiki/#{ ainu.id }", params: { locale: 'ain' } + get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana' } expect(json).to include('body' => 'native edit', 'revision_id' => second.id) put "/wiki/#{ ainu.id }", params: { - locale: 'ain', title: 'ainu_title', body: 'facade edit', + locale: 'ain-Kana', title: 'ainu_title', body: 'facade edit', base_revision_id: second.id }, headers: headers expect(response).to have_http_status(:ok) - get "/tags/#{ tag.id }/description", params: { locale: 'ain' } + get "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana' } expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3) expect(japanese.reload.attributes).to eq(snapshot) end @@ -133,7 +133,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes] expect do put "/wiki/#{ ainu.id }", params: { - locale: 'ain', title: 'conflicting_name', body: 'current', + locale: 'ain-Kana', title: 'conflicting_name', body: 'current', base_revision_id: stale.id }, headers: headers end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0) expect(response).to have_http_status(:conflict) @@ -145,14 +145,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do first = current_description_version(ainu) TagDescriptionOperation.update!( ainu_locale, tag:, body: 'later', updated_by_user: user) - get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id } + get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana', version: first.id } expect(response).to have_http_status(:ok) expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id) - get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id } + get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain-Kana', from: first.id } expect(response).to have_http_status(:ok) expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id) get "/wiki/#{ ainu.id }/diff", params: { - locale: 'ain', from: current_description_version(japanese).id } + locale: 'ain-Kana', from: current_description_version(japanese).id } expect(response).to have_http_status(:not_found) end end diff --git a/backend/spec/services/post_bulk_creator_spec.rb b/backend/spec/services/post_bulk_creator_spec.rb index d5167bb..2976143 100644 --- a/backend/spec/services/post_bulk_creator_spec.rb +++ b/backend/spec/services/post_bulk_creator_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe PostBulkCreator do - include_context 'English locale' + include_context 'Ainu locale' it 'limits workers to two and keeps failures in their request slots' do locale = Locale.nipponese @@ -67,8 +67,7 @@ RSpec.describe PostBulkCreator do 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 有効化は別課題で実装する' + it 'passes a future Ainu locale to preflight and creation in every worker' do locale actor = instance_double(User, id: 123) allow(User).to receive(:find).with(123).and_return(actor) diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 7d190ab..511346c 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -11,23 +11,22 @@ RSpec.describe PostCreatePlan do end context 'with an explicit locale' do - include_context 'English locale' + include_context 'Ainu 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) + ainu_name = create(:tag_name, name: 'shared_name', + language_code: 'ain', script_code: 'Kana') + ainu = create(:tag, primary_tag_name: ainu_name, category: :character) nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general) # The DB permits this identity; validation has its own contract spec. primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name') - english_parent_name = create(:tag_name, name: 'english_parent', - language_code: 'en', script_code: 'Latn') - english_parent = create(:tag, primary_tag_name: english_parent_name, + ainu_parent_name = create(:tag_name, name: 'ainu_parent', + language_code: 'ain', script_code: 'Kana') + ainu_parent = create(:tag, primary_tag_name: ainu_parent_name, category: :material) nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general) - TagImplication.create!(tag: english, parent_tag: english_parent) + TagImplication.create!(tag: ainu, parent_tag: ainu_parent) TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent) plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale) @@ -35,35 +34,34 @@ RSpec.describe PostCreatePlan do expect(plan[:direct_tag_specs]).to eq( [{ name: 'shared_name', category: :character }]) expect(plan[:post_tag_specs]).to include( - { name: primary_tag_name_for(english_parent, 'en').name, category: :material }) + { name: primary_tag_name_for(ainu_parent, 'ain').name, category: :material }) expect(plan[:post_tag_specs].pluck(:name)) .not_to include(primary_tag_name_for(nipponese_parent, 'ja').name) end it 'resolves aliases within the requested language' do - pending '日本語以外の Locale 有効化は別課題で実装する' locale nipponese = create(:tag, primary_name: 'nipponese_canonical') - english_name = create(:tag_name, name: 'english_canonical', - language_code: 'en', script_code: 'Latn') - english = create(:tag, primary_tag_name: english_name, category: :character) + ainu_name = create(:tag_name, name: 'ainu_canonical', + language_code: 'ain', script_code: 'Kana') + ainu = create(:tag, primary_tag_name: ainu_name, category: :character) nipponese_alias = create(:tag_name, :alias, name: 'shared_alias', tag: nipponese) - english_alias = create(:tag_name, :alias, name: 'shared_alias', - tag: english, - language_code: 'en', script_code: 'Latn') + ainu_alias = create(:tag_name, :alias, name: 'shared_alias', + tag: ainu, + language_code: 'ain', script_code: 'Kana') expect(nipponese_alias.reload).to have_attributes( tag_id: nipponese.id, primary_flg: false, language_code: 'ja') - expect(english_alias.reload).to have_attributes( - tag_id: english.id, - primary_flg: false, language_code: 'en') + expect(ainu_alias.reload).to have_attributes( + tag_id: ainu.id, + primary_flg: false, language_code: 'ain') plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) expect(plan[:direct_tag_specs]).to eq( - [{ name: english_name.name, category: :character }]) + [{ name: ainu_name.name, category: :character }]) end end diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb index 624577d..91e6c11 100644 --- a/backend/spec/services/post_creator_spec.rb +++ b/backend/spec/services/post_creator_spec.rb @@ -77,11 +77,10 @@ RSpec.describe PostCreator do end context 'with an explicit locale' do - include_context 'English locale' + include_context 'Ainu locale' [false, true].each do |planned| it "passes locale to tag creation with planned attributes: #{ planned }" do - pending '日本語以外の Locale 有効化は別課題で実装する' locale attributes = { title: 'Locale propagation', @@ -97,8 +96,8 @@ RSpec.describe PostCreator do 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) + tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character') + expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true) expect(post.tags).to include(tag_name.tag) end end diff --git a/backend/spec/services/tag_name_completion_spec.rb b/backend/spec/services/tag_name_completion_spec.rb new file mode 100644 index 0000000..ad94730 --- /dev/null +++ b/backend/spec/services/tag_name_completion_spec.rb @@ -0,0 +1,68 @@ +require 'rails_helper' + +RSpec.describe TagNameCompletion do + let!(:language) { Language.create!(code: 'ain', name: 'ain') } + let!(:script) { Script.create!(code: 'Kana', name: 'Kana') } + let(:locale) do + Locale.create!(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana', + region_code: 392, variants: '', name: 'ain-Kana') + end + + it 'generates a phonetic primary with the required identity and provenance' do + tag = create(:tag, primary_name: '漢字あカ') + + locale + + expect(tag.reload.tag_name('ain')).to have_attributes( + name: 'カンチ_ア_カ', language_code: 'ain', script_code: 'Kana', + primary_flg: true, auto_generated: true, tag_id: tag.id) + end + + it 'uses the exact fallback when the input cannot be converted' do + tag = create(:tag, primary_name: '😀') + + locale + + expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }") + end + + it 'uses the exact fallback when a generated name collides with a human name' do + other = create(:tag, primary_name: 'other') + create(:tag_name, tag: other, name: 'AbC', language_code: 'ain', script_code: 'Kana') + tag = create(:tag, primary_name: 'AbC') + + locale + + expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }") + expect(other.reload.name('ain')).to eq('AbC') + end + + it 'uses the exact fallback for a transcription that fails name validation' do + tag = create(:tag, primary_name: 'base') + source = TagName.new(name: 'nico:invalid', language_code: 'ja') + + expect(TagName.generate_name(locale, tag, source)).to eq("Tag_##{ tag.id }") + end + + it 'preserves human confirmation and is idempotent across repeated completion' do + locale + tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'あいぬ', category: :general) + primary = tag.tag_name('ain') + primary.update!(name: 'human_confirmed', auto_generated: false) + snapshot = tag.tag_names.order(:id).map(&:attributes) + + 2.times { described_class.for_tag!(tag) } + + expect(tag.tag_names.order(:id).map(&:attributes)).to eq(snapshot) + end + + it 'creates one primary per active language through the production creation path' do + locale + + tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'アイヌ', category: :general) + + expect(tag.tag_names.where(primary_flg: true).pluck(:language_code, :name)) + .to contain_exactly(['ja', 'アイヌ'], ['ain', 'アイヌ']) + expect(Tag.find_or_create_by_tag_name!(locale, 'アイヌ', category: :general)).to eq(tag) + end +end diff --git a/backend/spec/services/tag_name_transcription_spec.rb b/backend/spec/services/tag_name_transcription_spec.rb new file mode 100644 index 0000000..419d1cf --- /dev/null +++ b/backend/spec/services/tag_name_transcription_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe TagNameTranscription do + { + 'AbCdXYZ' => 'AbCdXYZ', + 'あいぬ' => 'アイヌ', + 'アイヌ' => 'アイヌ', + '日本語' => 'ニチホンコ', + '漢字' => 'カンチ', + '漢あカ' => 'カン_ア_カ', + '漢字あいうカナ' => 'カンチ_アイウ_カナ', + 'Ab漢あカZ' => 'Abカン_ア_カZ', + 'りゃリュリョ' => 'リヤ_リユリヨ', + 'キャット' => 'キヤット', + 'コーヒー' => 'コヒ', + 'コウコオケイケエカア' => 'コウコオケイケエカア', + 'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト', + 'バビブベボパピプペポ' => 'パピプペポパピプペポ', + 'ザジズゼゾ' => 'チャチチュチェチョ', + 'ジャジュジョ' => 'チヤチユチヨ', + 'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚', + 'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ', + '漢Aあ' => 'カンAア' }.each do |source, expected| + it "transcribes #{ source } as #{ expected }" do + expect(described_class.ainu(source)).to eq(expected) + end + end + + ['😀', 'ヴ', 'ティ', 'ㇵ', '魑', '𠮷', 'ー', '々'].each do |source| + it "rejects unsupported input #{ source }" do + expect { described_class.ainu(source) }.to raise_error(described_class::Unconvertible) + end + end +end diff --git a/backend/spec/services/tag_version_recorder_spec.rb b/backend/spec/services/tag_version_recorder_spec.rb index e403ef6..34b6c67 100644 --- a/backend/spec/services/tag_version_recorder_spec.rb +++ b/backend/spec/services/tag_version_recorder_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe TagVersionRecorder do - include_context 'English locale' + include_context 'Ainu locale' let(:user) { create_member_user! } let!(:tag) { create(:tag, primary_name: '日本語名') } @@ -17,11 +17,11 @@ RSpec.describe TagVersionRecorder do { 'name' => 'alias_z', 'script_code' => 'Jpan' }] } end - def prepare_english_names! + def prepare_ainu_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') + primary_tag_name_for(tag, 'ain').update!(name: 'Ainu name', auto_generated: false) + create(:tag_name, :alias, tag:, language_code: 'ain', script_code: 'Kana', + name: 'Ainu alias') end it 'snapshots the Nipponese primary and sorted aliases in grouped names' do @@ -62,35 +62,32 @@ RSpec.describe TagVersionRecorder do end it 'snapshots future languages together without a request locale argument' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_names! + prepare_ainu_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', + 'ain' => { 'name' => 'Ainu name', 'script_code' => 'Kana', 'auto_generated' => false, - 'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] }) + 'aliases' => [{ 'name' => 'Ainu alias', 'script_code' => 'Kana' }] }) end - it 'creates a version for an English-only change and preserves prior names' do - pending '日本語以外の Locale 有効化は別課題で実装する' - prepare_english_names! + it 'creates a version for an Ainu-only change and preserves prior names' do + prepare_ainu_names! original = described_class.record!(tag:, event_type: :create, created_by_user: user) - primary_tag_name_for(tag, 'en').update!(name: 'Renamed') + primary_tag_name_for(tag, 'ain').update!(name: 'Renamed') updated = described_class.record!(tag:, event_type: :update, created_by_user: user) expect(updated.version_no).to eq(2) - expect(original.reload.names.fetch('en')).to include('name' => 'English name') - expect(updated.names.fetch('en')).to include('name' => 'Renamed') + expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name') + expect(updated.names.fetch('ain')).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! + prepare_ainu_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/ainu_locale_contract.rb b/backend/spec/support/ainu_locale_contract.rb index 83187cf..0f79300 100644 --- a/backend/spec/support/ainu_locale_contract.rb +++ b/backend/spec/support/ainu_locale_contract.rb @@ -1,19 +1,22 @@ RSpec.shared_context 'Ainu locale contract' do - let(:ainu_locale) { instance_double(Locale, language_code: 'ain', script_code: 'Jpan') } + let(:ainu_locale) do + instance_double(Locale, language_code: 'ain', script_code: 'Kana', deprecated_at: nil) + end before do - # Only the foreign-key reference is seeded; no Locale or enum is enabled. + # Keep description contracts independent of locale activation and backfill. Language.connection.execute(<<~SQL) INSERT IGNORE INTO languages (code, name, created_at) VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP) SQL + Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' } allow(Locale).to receive(:find_by).and_call_original - allow(Locale).to receive(:find_by).with(code: 'ain').and_return(ainu_locale) + allow(Locale).to receive(:find_by).with(code: 'ain-Kana').and_return(ainu_locale) end def add_ainu_name tag, name TagName.insert_all!([{ - tag_id: tag.id, language_code: 'ain', script_code: 'Jpan', name:, + tag_id: tag.id, language_code: 'ain', script_code: 'Kana', name:, primary_flg: true, auto_generated: false, created_at: Time.current, updated_at: Time.current }]) tag.tag_names.reset diff --git a/backend/spec/support/locale_context.rb b/backend/spec/support/locale_context.rb index 3241f11..89c764e 100644 --- a/backend/spec/support/locale_context.rb +++ b/backend/spec/support/locale_context.rb @@ -1,22 +1,17 @@ -RSpec.shared_context 'English locale' do - def prepare_english_reference! - Language.find_or_create_by!(code: 'en') { _1.name = 'English' } - Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } - region_class = Class.new(ApplicationRecord) { self.table_name = 'regions' } - region_class.find_or_create_by!(code: 826) do |record| - record.alpha_code = 'GB' - record.name = 'United Kingdom' - end +RSpec.shared_context 'Ainu locale' do + def prepare_ainu_reference! + Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' } + Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' } 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 + prepare_ainu_reference! + Locale.find_or_create_by!(code: 'ain-Kana') do |record| + record.language_code = 'ain' + record.script_code = 'Kana' + record.region_code = 392 record.variants = '' - record.name = 'English' + record.name = 'Ainu' end end end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2367731..87f319f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,8 @@ import { createBrowserRouter, Route, RouterProvider, Routes, - useLocation } from 'react-router-dom' + useLocation, + useParams } from 'react-router-dom' import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' @@ -13,6 +14,8 @@ import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import { isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales' +import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, fetchUserThemeSlots, @@ -73,41 +76,41 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const routes = ( - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }> }/> }/> }/> }/> }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> }/> ) @@ -259,9 +262,27 @@ const RoutedApp: FC = () => { } -const router = createBrowserRouter ([{ - path: '*', - element: }]) +const LocaleRoute: FC = () => { + const { locale } = useParams () + const location = useLocation () + if (isLocaleCode (locale)) + return + + const redirect = legacyLocalePath (location) + return redirect == null ? : +} + + +const LocaleRedirect = ({ to }: { to: string }) => { + const locale = useLocale () + const location = useLocation () + return +} + + +const router = createBrowserRouter ([ + { path: '/:locale/*', element: }, + { path: '*', element: }]) const App: FC = () => ( diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 3fef9da..95b96f6 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -1,7 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import SidebarComponent from '@/components/layout/SidebarComponent' import TagLink from '@/components/TagLink' import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' @@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string => const updateMaterialFilterQuery = ( pathname: string, locationSearch: string, - navigate: ReturnType, + navigate: ReturnType, materialFilter: MaterialFilter, ) => { const qs = new URLSearchParams (locationSearch) @@ -125,10 +126,11 @@ const MaterialTreeNode: FC<{ tag: MaterialSidebarTag }> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId, setOpenTags, tag }) => { + const locale = useLocale () const open = Boolean (openTags[tag.id]) const { data } = useQuery ({ - queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), - queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), + queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale), + queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale), enabled: open && tag.hasChildren && tag.children.length === 0}) useEffect (() => { @@ -200,6 +202,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number tag, }, ) => { + const locale = useLocale () const open = Boolean (openTags[tag.id]) const tagColumnRef = useRef (null) const chipRef = useRef (null) @@ -210,8 +213,8 @@ const MobileMaterialTreeNode: FC<{ depth?: number const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState (null) const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState (null) const { data } = useQuery ({ - queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }), - queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }), + queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale), + queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale), enabled: open && tag.hasChildren && tag.children.length === 0}) useEffect (() => { @@ -382,8 +385,9 @@ const MobileMaterialTreeNode: FC<{ depth?: number const MaterialSidebar: FC = () => { + const locale = useLocale () const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const qs = new URLSearchParams (location.search) const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any') const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null @@ -395,8 +399,8 @@ const MaterialSidebar: FC = () => { useState (null) const { data: rootTags = [], isLoading, isError } = useQuery ({ - queryKey: materialsKeys.tree ({ parentId: null, materialFilter }), - queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })}) + queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale), + queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)}) useEffect (() => { setDesktopTags (rootTags) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 6d3fe3f..3cd7cf2 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' +import { useLocale } from '@/lib/useLocale' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import FieldError from '@/components/common/FieldError' import PostDurationField from '@/components/posts/PostDurationField' @@ -51,6 +52,7 @@ type Props = { post: Post const PostEditForm: FC = ({ post, onSave }) => { + const locale = useLocale () const [disabled, setDisabled] = useState (false) const [duration, setDuration] = useState (videoMsToDurationValue (post.videoMs)) const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = @@ -75,7 +77,7 @@ const PostEditForm: FC = ({ post, onSave }) => { try { - const data = await updatePost (...args) + const data = await updatePost (...args, undefined, locale) onSave ({ ...post, versionNo: data.versionNo, title: data.title, diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 68bcdaa..9d771a3 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react' +import { useLocale } from '@/lib/useLocale' import TagSearchBox from '@/components/TagSearchBox' import FormField from '@/components/common/FormField' import TextArea from '@/components/common/TextArea' @@ -38,6 +39,7 @@ type Props = Omit, 'value' | 'onChange' | ' const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { + const locale = useLocale () const ref = useRef (null) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) @@ -68,7 +70,7 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { setBounds ({ start, end }) - const data = await apiGet ('/tags/autocomplete', { params: { + const data = await apiGet ('/tags/autocomplete', { locale, params: { q: token, nico: '0' } }) const nextSuggestions = data.filter (t => t.postCount > 0) diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index be533a4..f7ef056 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -1,8 +1,10 @@ import { useQueryClient } from '@tanstack/react-query' import { forwardRef, useMemo } from 'react' import { flushSync } from 'react-dom' -import { createPath, useNavigate } from 'react-router-dom' +import { createPath } from 'react-router-dom' +import { localiseTo } from '@/lib/locales' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' @@ -32,13 +34,17 @@ export default forwardRef (({ if ('onClick' in rest) delete rest['onClick'] - const navigate = useNavigate () + const navigate = useLocaleNavigate () + const locale = useLocale () const qc = useQueryClient () const behaviourSettings = useClientBehaviourSettings () const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const path = useMemo ( - () => typeof to === 'string' ? to : createPath (to), - [to], + () => { + const localised = localiseTo (to, locale) + return typeof localised === 'string' ? localised : createPath (localised) + }, + [to, locale], ) const url = useMemo (() => { return (new URL (path, window.location.origin)).toString () @@ -98,7 +104,7 @@ export default forwardRef (({ if (!(ok) && cancelOnError) return - navigate (to, { replace, ...(state && { state }) }) + navigate (path, { replace, ...(state && { state }) }) } catch (ex) { diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 1949ec5..49d1041 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -12,6 +12,7 @@ import { useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useEffect, useMemo, useRef, useState } from 'react' +import { useLocale } from '@/lib/useLocale' import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' @@ -27,6 +28,7 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { dateString, originalCreatedAtString } from '@/lib/utils' +import type { LocaleCode } from '@/lib/locales' import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core' import type { FC, MutableRefObject, ReactNode } from 'react' @@ -180,8 +182,10 @@ const buildFlatTagByCategory = ( const changeCategory = async ( tagId: number, - category: Category): Promise => { - await apiPatch (`/tags/${ tagId }`, { category }) + category: Category, + locale: LocaleCode = 'ja', +): Promise => { + await apiPatch (`/tags/${ tagId }`, { category }, { locale }) } @@ -227,6 +231,7 @@ type ActiveTagDrag = { const TagDetailSidebar: FC = ({ className, post, sp }) => { + const locale = useLocale () sp = Boolean (sp) const qc = useQueryClient () @@ -271,7 +276,7 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) const reloadTags = async (): Promise => { - setTags (buildTagByCategory (await apiGet (`/posts/${ post.id }`))) + setTags (buildTagByCategory (await apiGet (`/posts/${ post.id }`, { locale }))) qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) } @@ -314,9 +319,9 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { return if (fromParentId != null) - await apiDelete (`/tags/${ fromParentId }/children/${ childId }`) + await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale }) - await apiPost (`/tags/${ parentId }/children/${ childId }`, { }) + await apiPost (`/tags/${ parentId }/children/${ childId }`, { }, { locale }) await reloadTags () toast ({ @@ -333,10 +338,10 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { return if (child.category !== cat) - await changeCategory (childId, cat) + await changeCategory (childId, cat, locale) if (fromParentId != null) - await apiDelete (`/tags/${ fromParentId }/children/${ childId }`) + await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale }) const fromParent = fromParentId == null ? null : findTag (tags, fromParentId) diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index dfa519a..557f2a1 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -1,8 +1,9 @@ // TODO: タグ入力系すべてに同様の処理あるため共通化する. import { useEffect, useState } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' +import { useLocation } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { apiGet } from '@/lib/api' import { inputClass } from '@/lib/utils' @@ -14,8 +15,9 @@ import type { Tag } from '@/types' const TagSearch: FC = () => { + const locale = useLocale () const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const [activeIndex, setActiveIndex] = useState (-1) const [search, setSearch] = useState ('') @@ -32,7 +34,7 @@ const TagSearch: FC = () => { return } - const data = await apiGet ('/tags/autocomplete', { params: { q } }) + const data = await apiGet ('/tags/autocomplete', { locale, params: { q } }) setSuggestions (data.filter (t => t.postCount > 0)) if (suggestions.length > 0) setSuggestionsVsbl (true) diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 6ba28de..16c66f0 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -1,7 +1,8 @@ import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import TagLink from '@/components/TagLink' import TagSearch from '@/components/TagSearch' import SectionTitle from '@/components/common/SectionTitle' @@ -22,7 +23,8 @@ type Props = { posts: Post[] const TagSidebar: FC = ({ posts, onClick }) => { - const navigate = useNavigate () + const locale = useLocale () + const navigate = useLocaleNavigate () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const animationsOff = animationMode === 'off' @@ -96,7 +98,7 @@ const TagSidebar: FC = ({ posts, onClick }) => { try { const data = await apiGet ('/posts/random', - { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), + { locale, params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), match: (anyFlg ? 'any' : 'all') } }) navigate (`/posts/${ data.id }`) } diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 5980ebc..429fbf0 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -3,10 +3,18 @@ import { AnimatePresence, motion } from 'framer-motion' import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' +import { LOCALE_LABELS, + SUPPORTED_LOCALES, + isLocaleCode, + localeFromPath, + switchLocalePath, + unlocalisedPath } from '@/lib/locales' +import { uiMessage, uiMessageLanguage } from '@/lib/messages' import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' @@ -24,13 +32,16 @@ type NavDensity = 'auto' | 'comfortable' | 'compact' export const menuOutline = ( - { tag, material, wikiId, user, pathName }: { + { tag, material, wikiId, user, pathName: fullPathname }: { tag?: Tag | null material?: Material | null wikiId: number | null user: User | null pathName: string }, ): Menu => { + const pathName = unlocalisedPath (fullPathname) + const locale = localeFromPath (fullPathname) ?? 'ja' + const message = (key: Parameters[1]) => uiMessage (locale, key) const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0 const editable = canEditContent (user) @@ -41,71 +52,73 @@ export const menuOutline = ( const materialFlg = /^\/materials\/\d+/.test (pathName) - const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName) + const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName) const helpIdentifier = pathName.split ('/')[2] ?? '' return [ - { name: '広場', to: '/posts', subMenu: [ - { name: '一覧', to: '/posts' }, - { name: '検索', to: '/posts/search' }, - { name: '追加', to: '/posts/new', visible: editable }, - { name: '全体履歴', to: '/posts/changes' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, - { name: 'タグ', to: '/tags', subMenu: [ - { name: 'マスタ', to: '/tags' }, - { name: 'ニコニコ連携', to: '/tags/nico' }, - { name: '全体履歴', to: '/tags/changes' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, + { name: message ('square'), to: '/posts', subMenu: [ + { name: message ('list'), to: '/posts' }, + { name: message ('search'), to: '/posts/search' }, + { name: message ('add'), to: '/posts/new', visible: editable }, + { name: message ('allHistory'), to: '/posts/changes' }, + { name: message ('help'), to: '/wiki/ヘルプ:広場' }] }, + { name: message ('tags'), to: '/tags', subMenu: [ + { name: message ('master'), to: '/tags' }, + { name: message ('nicoLinks'), to: '/tags/nico' }, + { name: message ('allHistory'), to: '/tags/changes' }, + { name: message ('help'), to: '/wiki/ヘルプ:タグ' }, { component: , visible: tagFlg }, { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, visible: tagFlg }, - { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, + { name: message ('history'), to: `/tags/changes?id=${ tag?.id }`, visible: tagFlg && tag?.category !== 'nico' }] }, - { name: '素材', to: '/materials', visible: true, subMenu: [ - { name: '一覧', to: '/materials' }, - { name: '追加', to: '/materials/new', visible: editable }, - { name: '抑止', to: '/materials/suppressions', + { name: message ('materials'), to: '/materials', visible: true, subMenu: [ + { name: message ('list'), to: '/materials' }, + { name: message ('add'), to: '/materials/new', visible: editable }, + { name: message ('suppressions'), to: '/materials/suppressions', visible: user?.role === 'admin' }, - { name: '全体履歴', to: '/materials/changes' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' }, + { name: message ('allHistory'), to: '/materials/changes' }, + { name: message ('help'), to: '/wiki/ヘルプ:素材管理' }, { component: , visible: materialFlg }, { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`, visible: materialFlg && Boolean (material?.tag) }, - { name: '履歴', to: `/materials/changes?material_id=${ material?.id }`, + { name: message ('history'), to: `/materials/changes?material_id=${ material?.id }`, visible: materialFlg }] }, - { name: 'Wiki', to: '/wiki', subMenu: [ - { name: '検索', to: '/wiki' }, - { name: '新規', to: '/wiki/new', visible: editable }, - { name: '全体履歴', to: '/wiki/changes' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' }, + { name: message ('wiki'), to: '/wiki', subMenu: [ + { name: message ('search'), to: '/wiki' }, + { name: message ('create'), to: '/wiki/new', visible: editable }, + { name: message ('allHistory'), to: '/wiki/changes' }, + { name: message ('help'), to: '/wiki/ヘルプ:Wiki' }, { component: , visible: wikiPageFlg }, { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`, visible: wikiPageFlg }, - { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, - { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, + { name: message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, + { name: message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg && editable }] }, - { name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [ - { name: '検索', to: '/help' }, - { name: '全体履歴', to: '/help/changes' }, + { name: message ('help'), to: '/help/home', base: '/help', subMenu: [ + { name: message ('search'), to: '/help' }, + { name: message ('allHistory'), to: '/help/changes' }, { component: , visible: helpVsbl }, - { name: '履歴', + { name: message ('history'), to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] }, - { name: 'おたのしみ', visible: false, subMenu: [ - { name: '上映会 (β)', to: '/theatres/1' }, - { name: 'グカネータ (β)', to: '/gekanator' }] }, - { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ - { name: '一覧', to: '/users', visible: false }, - { name: 'お前', to: `/users/${ user?.id }`, visible: false }, - { name: '設定', to: '/users/settings', visible: Boolean (user) }] }, - { name: '法規', visible: false, subMenu: [ - { name: '利用規約', to: '/tos' }] }] + { name: message ('entertainment'), visible: false, subMenu: [ + { name: message ('theatre'), to: '/theatres/1' }, + { name: message ('gekanator'), to: '/gekanator' }] }, + { name: message ('users'), to: '/users/settings', visible: false, subMenu: [ + { name: message ('list'), to: '/users', visible: false }, + { name: message ('currentUser'), to: `/users/${ user?.id }`, visible: false }, + { name: message ('settings'), to: '/users/settings', visible: Boolean (user) }] }, + { name: message ('legal'), visible: false, subMenu: [ + { name: message ('terms'), to: '/tos' }] }] } const TopNav: FC = ({ user }) => { + const locale = useLocale () + const navigate = useLocaleNavigate () const location = useLocation () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' @@ -151,25 +164,26 @@ const TopNav: FC = ({ user }) => { const { data: wikiPage } = useQuery ({ enabled: Boolean (wikiIdStr), - queryKey: wikiKeys.show (wikiIdStr, { }), - queryFn: () => fetchWikiPage (wikiIdStr, { }) }) + queryKey: wikiKeys.show (wikiIdStr, { }, locale), + queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) }) - const tagFlg = /^\/tags\/\d+/.test (location.pathname) - const materialFlg = /^\/materials\/\d+/.test (location.pathname) + const pathname = unlocalisedPath (location.pathname) + const tagFlg = /^\/tags\/\d+/.test (pathname) + const materialFlg = /^\/materials\/\d+/.test (pathname) const effectiveTitle = (((tagFlg || materialFlg) - ? location.pathname.split ('/')[2] + ? pathname.split ('/')[2] : wikiPage?.title) ?? '') const { data: tag } = useQuery ({ enabled: Boolean (effectiveTitle), - queryKey: tagsKeys.show (effectiveTitle), - queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) }) + queryKey: tagsKeys.show (effectiveTitle, locale), + queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) }) const { data: material } = useQuery ({ enabled: Boolean (effectiveTitle), - queryKey: materialsKeys.show (effectiveTitle), - queryFn: () => fetchMaterial (effectiveTitle) }) + queryKey: materialsKeys.show (effectiveTitle, locale), + queryFn: () => fetchMaterial (effectiveTitle, locale) }) const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) @@ -177,7 +191,7 @@ const TopNav: FC = ({ user }) => { !(item.visible ?? true) || item.subMenu.filter (subItem => subItem.visible ?? true).length > 0) const activeIdx = - visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) + visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to)) const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const topNavTransition = animationsOff @@ -298,7 +312,7 @@ const TopNav: FC = ({ user }) => { return (
- +
+ +
+ ( const WikiBody: FC = ({ title, body }) => { + const locale = useLocale () const { data } = useQuery ({ enabled: Boolean (body), - queryKey: wikiKeys.index ({ }), - queryFn: () => fetchWikiPages ({ }) }) + queryKey: wikiKeys.index ({ }, locale), + queryFn: () => fetchWikiPages ({ }, locale) }) const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length) const remarkPlugins = useMemo ( diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index 8eec0b6..f642c51 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' +import { useLocale } from '@/lib/useLocale' import TagSearchBox from '@/components/TagSearchBox' import { apiGet } from '@/lib/api' import { inputClass } from '@/lib/utils' @@ -24,6 +25,7 @@ const TagInput: FC = ({ value, setValue, }) => { + const locale = useLocale () const [activeIndex, setActiveIndex] = useState (-1) const [suggestions, setSuggestions] = useState ([]) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) @@ -39,7 +41,7 @@ const TagInput: FC = ({ return } - const data = await apiGet ('/tags/autocomplete', { params: { + const data = await apiGet ('/tags/autocomplete', { locale, params: { q, nico: includeNico ? '1' : '0' } }) const nextSuggestions = data.filter (t => t.postCount > 0) diff --git a/frontend/src/components/dialogues/DialogueProvider.tsx b/frontend/src/components/dialogues/DialogueProvider.tsx index 01ff21e..7170f76 100644 --- a/frontend/src/components/dialogues/DialogueProvider.tsx +++ b/frontend/src/components/dialogues/DialogueProvider.tsx @@ -88,8 +88,9 @@ const DialogueProvider: FC = ({ children }) => { }) setPendingIds (current => current.filter (pendingId => pendingId !== id)) setFormActions (current => { - const { [id]: _, ...rest } = current - return rest + const rest = { ...current } + delete rest[id] + return rest }) delete formControls.current[id] }, []) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7f9148d..4474903 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,7 +5,10 @@ import { API_BASE_URL } from '@/config' import type { AxiosError, AxiosRequestConfig } from 'axios' +import type { LocaleCode } from '@/lib/locales' + type Opt = { + locale?: LocaleCode params?: AxiosRequestConfig['params'] headers?: Record signal?: AbortSignal @@ -14,10 +17,14 @@ type Opt = { const client = axios.create ({ baseURL: API_BASE_URL }) -const withUserCode = (opt?: Opt): Opt => ({ - ...opt, - headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '', - ...(opt?.headers ?? { }) } }) +const withUserCode = (opt?: Opt): AxiosRequestConfig => { + const { locale, ...config } = opt ?? { } + return { + ...config, + ...(locale != null && { params: { locale, ...config.params } }), + headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '', + ...(config.headers ?? { }) } } +} const apiP = async ( diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 0acb338..0f178c6 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -1,5 +1,6 @@ import { apiGet, apiPost } from '@/lib/api' +import type { LocaleCode } from '@/lib/locales' import type { Post } from '@/types' export type GekanatorAnswerValue = @@ -411,24 +412,30 @@ export const storeGekanatorQuestion = ( exampleAnswers: question.exampleAnswers }) -export const fetchGekanatorPosts = async (): Promise => { - const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts') +export const fetchGekanatorPosts = async ( + locale: LocaleCode = 'ja', +): Promise => { + const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts', { locale }) return data.posts } -export const fetchGekanatorQuestions = async (): Promise => { - const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions') +export const fetchGekanatorQuestions = async ( + locale: LocaleCode = 'ja', +): Promise => { + const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions', { locale }) return data.questions } export const fetchGekanatorExtraQuestions = async ( gameId: number, - nonce?: string): Promise => { + nonce?: string, + locale: LocaleCode = 'ja', +): Promise => { const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( `/gekanator/games/${ gameId }/extra_questions`, - { params: nonce ? { nonce } : undefined }) + { locale, params: nonce ? { nonce } : undefined }) return data.questions } @@ -606,7 +613,8 @@ export const buildGekanatorQuestions = ( } -export const saveGekanatorGame = async ({ +export const saveGekanatorGame = async ( + { guessedPostId, correctPostId, answers, @@ -614,7 +622,9 @@ export const saveGekanatorGame = async ({ guessedPostId: number correctPostId: number answers: GekanatorAnswerLog[] -}): Promise<{ id: number; learnedExampleCount: number }> => +}, + locale: LocaleCode = 'ja', +): Promise<{ id: number; learnedExampleCount: number }> => await apiPost ('/gekanator/games', { guessed_post_id: guessedPostId, correct_post_id: correctPostId, @@ -627,10 +637,11 @@ export const saveGekanatorGame = async ({ effective_question: answer.effectiveQuestion, learning_question: answer.learningQuestion, answer: answer.answer, - original_answer: answer.originalAnswer })) }) + original_answer: answer.originalAnswer })) }, { locale }) -export const saveGekanatorQuestionSuggestion = async ({ +export const saveGekanatorQuestionSuggestion = async ( + { gekanatorGameId, existingQuestionId, questionText, @@ -640,22 +651,27 @@ export const saveGekanatorQuestionSuggestion = async ({ existingQuestionId?: number questionText?: string answer: GekanatorAnswerValue -}): Promise<{ id: number; count: number }> => +}, + locale: LocaleCode = 'ja', +): Promise<{ id: number; count: number }> => await apiPost ('/gekanator/question_suggestions', { gekanator_game_id: gekanatorGameId, existing_question_id: existingQuestionId, question_text: questionText, - answer }) + answer }, { locale }) -export const saveGekanatorExtraQuestionAnswers = async ({ +export const saveGekanatorExtraQuestionAnswers = async ( + { gameId, answers, }: { gameId: number answers: { questionId: number; answer: GekanatorAnswerValue }[] -}) => +}, + locale: LocaleCode = 'ja', +) => await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, { answers: answers.map (item => ({ question_id: item.questionId, - answer: item.answer })) }) + answer: item.answer })) }, { locale }) diff --git a/frontend/src/lib/help.ts b/frontend/src/lib/help.ts index 36b6207..affd2b2 100644 --- a/frontend/src/lib/help.ts +++ b/frontend/src/lib/help.ts @@ -13,12 +13,14 @@ export type HelpEditionText = { export type HelpSearchEntry = HelpEditionText & { identifier: string + language?: string edition: string } export type HelpEditionContent = HelpEditionText & { content: ComponentType } export type HelpEditionSource = HelpEditionText & { source: string } export type HelpChange = Pick & { + language?: string pred: string | null change: 'new' | 'update' date: string } @@ -82,6 +84,15 @@ export const getHelpArticle = async (identifier: string): Promise + article?.languages[requestedLanguage] != null + ? requestedLanguage + : article?.canonicalLanguage ?? 'ja' + + export const getHelpEditions = async ( identifier: string, language: string, @@ -144,7 +155,8 @@ export const getHelpSearchEntries = async ( language: string, ): Promise => { const { default: search } = await import ('virtual:help/search') - return search[language] ?? [] + const contentLanguage = search[language] != null ? language : 'ja' + return (search[contentLanguage] ?? []).map (entry => ({ ...entry, language: contentLanguage })) } @@ -163,13 +175,14 @@ export const getHelpEditionContent = async ( export const getHelpChanges = async (language: string): Promise => { const { default: changes } = await import ('virtual:help/changes') - return changes[language] ?? [] + const contentLanguage = changes[language] != null ? language : 'ja' + return (changes[contentLanguage] ?? []).map (entry => ({ ...entry, language: contentLanguage })) } export const getHelpTitleIndex = async (language: string): Promise => { const { default: titles } = await import ('virtual:help/titles') - return titles[language] ?? { } + return titles[language] ?? titles.ja ?? { } } diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts index 78be271..121ca6d 100644 --- a/frontend/src/lib/keyboardShortcuts.ts +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -1,3 +1,5 @@ +import { unlocalisedPath } from '@/lib/locales' + export type ShortcutScope = | 'global' | 'posts' @@ -308,7 +310,8 @@ export const getConflictingShortcutActionIds = ( ) } -export const resolveShortcutScope = (pathname: string): ShortcutScope => { +export const resolveShortcutScope = (fullPathname: string): ShortcutScope => { + const pathname = unlocalisedPath (fullPathname) if (pathname.startsWith ('/posts/search')) return 'postSearch' if (pathname.startsWith ('/posts')) diff --git a/frontend/src/lib/locales.test.ts b/frontend/src/lib/locales.test.ts new file mode 100644 index 0000000..10ef552 --- /dev/null +++ b/frontend/src/lib/locales.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' + +import { isLocaleCode, + legacyLocalePath, + localeFromPath, + localiseTo, + switchLocalePath, + unlocalisedPath } from '@/lib/locales' + + +describe ('locale URL generation', () => { + it.each (['ja', 'ain-Kana']) ('recognises the supported locale %s', locale => { + expect (isLocaleCode (locale)).toBe (true) + expect (localeFromPath (`/${ locale }/posts`)).toBe (locale) + }) + + it.each (['en', 'ain', 'JA', '', undefined]) ('rejects %s', locale => { + expect (isLocaleCode (locale)).toBe (false) + }) + + it.each ([ + '/posts', '/posts/new', '/posts/search', '/posts/42', '/posts/changes', + '/tags', '/tags/42', '/tags/42/deerjikists', '/tags/nico', '/nico/tags', '/tags/changes', + '/materials', '/materials/new', '/materials/42', '/materials/changes', + '/materials/suppressions', '/theatres/42', '/wiki', '/wiki/new', '/wiki/title', + '/wiki/42/edit', '/wiki/42/diff', '/wiki/changes', '/help', '/help/changes', + '/help/article', '/help/article/diff', '/users/settings', '/settings', + '/tos', '/gekanator', '/more']) ('localises and redirects %s', pathname => { + const location = { pathname, search: '?page=2&tag=a%23b', hash: '#section' } + expect (localiseTo (pathname, 'ain-Kana')).toBe (`/ain-Kana${ pathname }`) + expect (legacyLocalePath (location)).toBe (`/ja${ pathname }${ location.search }#section`) + expect (unlocalisedPath (`/ain-Kana${ pathname }`)).toBe (pathname) + }) + + it ('preserves the resource, query and fragment on locale changes', () => { + expect (switchLocalePath ({ pathname: '/ja/tags/42', search: '?x=1&x=2', hash: '#history' }, + 'ain-Kana')).toBe ('/ain-Kana/tags/42?x=1&x=2#history') + }) + + it ('preserves explicit locales and relative, fragment and external targets', () => { + for (const target of ['/ja/posts', '/ain-Kana/posts', '?page=2', '#body', + '../changes', 'https://example.com/posts', '//example.com/posts']) + expect (localiseTo (target, 'ain-Kana')).toBe (target) + }) + + it ('preserves structured targets', () => { + expect (localiseTo ({ pathname: '/tags/42', search: '?q=one', hash: '#two' }, 'ain-Kana')) + .toEqual ({ pathname: '/ain-Kana/tags/42', search: '?q=one', hash: '#two' }) + }) + + it.each (['/en/posts', '/ain/posts', '/unknown', '/api/tags', '/assets/image.png']) ( + 'does not redirect invalid or non-page paths: %s', pathname => { + expect (legacyLocalePath ({ pathname, search: '', hash: '' })).toBeNull () + }) +}) diff --git a/frontend/src/lib/locales.ts b/frontend/src/lib/locales.ts new file mode 100644 index 0000000..b707927 --- /dev/null +++ b/frontend/src/lib/locales.ts @@ -0,0 +1,62 @@ +import type { To } from 'react-router-dom' + +export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const + +export type LocaleCode = typeof SUPPORTED_LOCALES[number] + +export const LOCALE_LABELS: Record = { + ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' } + +const LEGACY_ROOTS = [ + 'posts', 'tags', 'nico', 'theatres', 'materials', 'wiki', 'help', + 'users', 'settings', 'tos', 'gekanator', 'more'] + + +export const isLocaleCode = (value: unknown): value is LocaleCode => + SUPPORTED_LOCALES.some (locale => locale === value) + + +export const localeLanguage = (locale: LocaleCode): 'ja' | 'ain' => + locale === 'ja' ? 'ja' : 'ain' + + +export const localeFromPath = (pathname: string): LocaleCode | null => { + const segment = pathname.split (/[/?#]/)[1] + return isLocaleCode (segment) ? segment : null +} + + +export const unlocalisedPath = (pathname: string): string => { + const locale = localeFromPath (pathname) + return locale == null ? pathname : pathname.slice (locale.length + 1) || '/' +} + + +export const localiseTo = (to: To, locale: LocaleCode): To => { + const pathname = typeof to === 'string' ? to : to.pathname + if (pathname == null + || !(pathname.startsWith ('/')) + || pathname.startsWith ('//') + || localeFromPath (pathname) != null) + return to + + const localised = `/${ locale }${ pathname }` + return typeof to === 'string' ? localised : { ...to, pathname: localised } +} + + +export const switchLocalePath = ( + location: { pathname: string; search: string; hash: string }, + locale: LocaleCode, +): string => + `/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }` + + +export const legacyLocalePath = ( + location: { pathname: string; search: string; hash: string }, +): string | null => { + const root = location.pathname.split ('/')[1] + return (location.pathname === '/' || LEGACY_ROOTS.includes (root)) + ? `/ja${ location.pathname }${ location.search }${ location.hash }` + : null +} diff --git a/frontend/src/lib/materials.ts b/frontend/src/lib/materials.ts index ab9e044..78f0ed4 100644 --- a/frontend/src/lib/materials.ts +++ b/frontend/src/lib/materials.ts @@ -1,5 +1,6 @@ import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api' +import type { LocaleCode } from '@/lib/locales' import type { Material, MaterialIndexResponse, MaterialVersion, @@ -36,8 +37,10 @@ export const fetchMaterials = async ( updatedFrom, updatedTo, sort, direction, page, tagId, includeDescendants, groupBy, limit }: FetchMaterialsParams, + locale: LocaleCode = 'ja', + ): Promise => - await apiGet ('/materials', { params: { + await apiGet ('/materials', { locale, params: { ...(q && { q }), tag_state: tagState, media_kind: mediaKind, @@ -54,10 +57,13 @@ export const fetchMaterials = async ( limit} }) -export const fetchMaterial = async (id: string): Promise => { +export const fetchMaterial = async ( + id: string, + locale: LocaleCode = 'ja', +): Promise => { try { - return await apiGet (`/materials/${ id }`) + return await apiGet (`/materials/${ id }`, { locale }) } catch (error) { @@ -75,8 +81,10 @@ export const fetchMaterialChanges = async ( eventType?: string page: number limit: number - }): Promise => - await apiGet ('/materials/versions', { params: { + }, + locale: LocaleCode = 'ja', +): Promise => + await apiGet ('/materials/versions', { locale, params: { ...(materialId && { material_id: materialId }), ...(tag && { tag }), ...(eventType && { event_type: eventType }), @@ -85,19 +93,23 @@ export const fetchMaterialChanges = async ( export const fetchMaterialTagTree = async ( - { parentId, materialFilter }: FetchMaterialTreeParams): Promise => - await apiGet ('/tags/with-depth', { params: { + { parentId, materialFilter }: FetchMaterialTreeParams, + locale: LocaleCode = 'ja', +): Promise => + await apiGet ('/tags/with-depth', { locale, params: { ...(parentId != null && { parent: String (parentId) }), material_filter: materialFilter} }) export const fetchMaterialTagByName = async ( name: string, - materialFilter: MaterialFilter): Promise => { + materialFilter: MaterialFilter, + locale: LocaleCode = 'ja', +): Promise => { try { return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`, - { params: { material_filter: materialFilter } }) + { locale, params: { material_filter: materialFilter } }) } catch (error) { @@ -108,19 +120,26 @@ export const fetchMaterialTagByName = async ( } -export const createMaterial = async (formData: FormData): Promise => - await apiPost ('/materials', formData) +export const createMaterial = async ( + formData: FormData, + locale: LocaleCode = 'ja', +): Promise => + await apiPost ('/materials', formData, { locale }) export const updateMaterial = async ( id: string, - formData: FormData): Promise => - await apiPut (`/materials/${ id }`, formData) + formData: FormData, + locale: LocaleCode = 'ja', +): Promise => + await apiPut (`/materials/${ id }`, formData, { locale }) export const fetchMaterialSyncSuppressions = - async (): Promise => - await apiGet ('/materials/suppressions') + async ( + locale: LocaleCode = 'ja', +): Promise => + await apiGet ('/materials/suppressions', { locale }) export const createMaterialSyncSuppression = async ( @@ -130,10 +149,12 @@ export const createMaterialSyncSuppression = async ( drivePath?: string driveFileId?: string reason: string - }): Promise => + }, + locale: LocaleCode = 'ja', +): Promise => await apiPost ('/materials/suppressions', { source_kind: payload.sourceKind, source_uri: payload.sourceUri, drive_path: payload.drivePath, drive_file_id: payload.driveFileId, - reason: payload.reason}) + reason: payload.reason}, { locale }) diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts new file mode 100644 index 0000000..da6ee9b --- /dev/null +++ b/frontend/src/lib/messages.ts @@ -0,0 +1,38 @@ +import type { LocaleCode } from '@/lib/locales' + +const japanese = { + square: '広場', + list: '一覧', + search: '検索', + add: '追加', + history: '履歴', + allHistory: '全体履歴', + help: 'ヘルプ', + tags: 'タグ', + master: 'マスタ', + nicoLinks: 'ニコニコ連携', + materials: '素材', + suppressions: '抑止', + wiki: 'Wiki', + create: '新規', + edit: '編輯', + entertainment: 'おたのしみ', + theatre: '上映会 (β)', + gekanator: 'グカネータ (β)', + users: 'ユーザ', + currentUser: 'お前', + settings: '設定', + legal: '法規', + terms: '利用規約' } + +export type UIMessageKey = keyof typeof japanese + +const ainu: Partial> = { } + + +export const uiMessage = (locale: LocaleCode, key: UIMessageKey): string => + (locale === 'ain-Kana' ? ainu[key] : null) ?? japanese[key] + + +export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' => + (locale === 'ain-Kana' && ainu[key] != null) ? 'ain' : 'ja' diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 2b9822d..7eb1334 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -1,14 +1,17 @@ import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api' +import type { LocaleCode } from '@/lib/locales' import type { FetchPostsParams, Post, PostVersion } from '@/types' export const fetchPosts = async ( { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, - originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{ + originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, + locale: LocaleCode = 'ja', +): Promise<{ posts: Post[] count: number }> => - await apiGet ('/posts', { params: { + await apiGet ('/posts', { locale, params: { ...(url && { url }), ...(title && { title }), ...(tags && { tags }), @@ -24,7 +27,10 @@ export const fetchPosts = async ( ...(order && { order }) } }) -export const fetchPost = async (id: string): Promise => await apiGet (`/posts/${ id }`) +export const fetchPost = async ( + id: string, + locale: LocaleCode = 'ja', +): Promise => await apiGet (`/posts/${ id }`, { locale }) export const fetchPostChanges = async ( @@ -33,10 +39,12 @@ export const fetchPostChanges = async ( tag?: string externalTag?: string page: number - limit: number }): Promise<{ + limit: number }, + locale: LocaleCode = 'ja', +): Promise<{ versions: PostVersion[] count: number }> => - await apiGet ('/posts/versions', { params: { ...(post && { post }), + await apiGet ('/posts/versions', { locale, params: { ...(post && { post }), ...(tag && { tag }), ...(externalTag && { external_tag: externalTag }), @@ -54,7 +62,9 @@ export const updatePost = async ( { baseVersionNo, force, merge }: { baseVersionNo?: number force?: boolean - merge?: boolean }) => + merge?: boolean }, + locale: LocaleCode = 'ja', +) => await apiPut ( `/posts/${ post.id }`, { title: post.title, @@ -63,7 +73,7 @@ export const updatePost = async ( parent_post_ids: post.parentPostIds, original_created_from: post.originalCreatedFrom, original_created_before: post.originalCreatedBefore }, - { params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }), + { locale, params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }), force: force ? '1' : '0', merge: merge ? '1' : '0' } }) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 0b1ce8a..fabb319 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -5,7 +5,9 @@ import { getHelpArticle, getHelpChanges, getHelpDiff, getHelpEditionContent, - getHelpSearchEntries } from '@/lib/help' + getHelpSearchEntries, + resolveHelpLanguage } from '@/lib/help' +import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' @@ -13,9 +15,10 @@ import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' +import type { LocaleCode } from '@/lib/locales' import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' -type Prefetcher = (qc: QueryClient, url: URL) => Promise +type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise const mPost = match<{ id: string }> ('/posts/:id') const mWiki = match<{ title: string }> ('/wiki/:title') @@ -28,25 +31,25 @@ const boolFromQuery = (value: string | null): boolean => ['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) -const prefetchHelpIndex: Prefetcher = async qc => { +const prefetchHelpIndex: Prefetcher = async (qc, url, locale) => { await qc.prefetchQuery ({ - queryKey: helpKeys.index ('ja'), - queryFn: () => getHelpSearchEntries ('ja'), + queryKey: helpKeys.index (localeLanguage (locale)), + queryFn: () => getHelpSearchEntries (localeLanguage (locale)), staleTime: Infinity }) } -const prefetchHelpShow: Prefetcher = async (qc, url) => { +const prefetchHelpShow: Prefetcher = async (qc, url, locale) => { const matched = mHelp (url.pathname) if (!(matched)) return const { identifier } = matched.params - const language = 'ja' const article = await qc.fetchQuery ({ queryKey: helpKeys.article (identifier), queryFn: () => getHelpArticle (identifier), staleTime: Infinity }) + const language = resolveHelpLanguage (article, localeLanguage (locale)) const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition if (edition == null) return @@ -58,15 +61,15 @@ const prefetchHelpShow: Prefetcher = async (qc, url) => { } -const prefetchHelpChanges: Prefetcher = async qc => { +const prefetchHelpChanges: Prefetcher = async (qc, url, locale) => { await qc.prefetchQuery ({ - queryKey: helpKeys.changes ('ja'), - queryFn: () => getHelpChanges ('ja'), + queryKey: helpKeys.changes (localeLanguage (locale)), + queryFn: () => getHelpChanges (localeLanguage (locale)), staleTime: Infinity }) } -const prefetchHelpDiff: Prefetcher = async (qc, url) => { +const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => { const matched = mHelpDiff (url.pathname) const fromValues = url.searchParams.getAll ('from') const toValues = url.searchParams.getAll ('to') @@ -76,23 +79,28 @@ const prefetchHelpDiff: Prefetcher = async (qc, url) => { return const { identifier } = matched.params + const article = await qc.fetchQuery ({ + queryKey: helpKeys.article (identifier), + queryFn: () => getHelpArticle (identifier), + staleTime: Infinity }) + const language = resolveHelpLanguage (article, localeLanguage (locale)) await qc.prefetchQuery ({ - queryKey: helpKeys.diff (identifier, 'ja', from, to), - queryFn: () => getHelpDiff (identifier, 'ja', from, to), + queryKey: helpKeys.diff (identifier, language, from, to), + queryFn: () => getHelpDiff (identifier, language, from, to), staleTime: Infinity }) } -const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { +const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => { const title = url.searchParams.get ('title') ?? '' await qc.prefetchQuery ({ - queryKey: wikiKeys.index ({ title }), - queryFn: () => fetchWikiPages ({ title }) }) + queryKey: wikiKeys.index ({ title }, locale), + queryFn: () => fetchWikiPages ({ title }, locale) }) } -const prefetchWikiPageShow: Prefetcher = async (qc, url) => { +const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => { const m = mWiki (url.pathname) if (!(m)) return @@ -101,29 +109,29 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => { const version = url.searchParams.get ('version') || undefined const wikiPage = await qc.fetchQuery ({ - queryKey: wikiKeys.show (title, { version }), - queryFn: () => fetchWikiPageByTitle (title, { version }) }) + queryKey: wikiKeys.show (title, { version }, locale), + queryFn: () => fetchWikiPageByTitle (title, { version }, locale) }) if (wikiPage) { const effectiveId = String (wikiPage.id ?? '') await qc.prefetchQuery ({ - queryKey: wikiKeys.show (effectiveId, { }), - queryFn: () => fetchWikiPage (effectiveId, { } ) }) + queryKey: wikiKeys.show (effectiveId, { }, locale), + queryFn: () => fetchWikiPage (effectiveId, { }, locale ) }) if (wikiPage.body) { await qc.prefetchQuery ({ - queryKey: wikiKeys.index ({ }), - queryFn: () => fetchWikiPages ({ }) }) + queryKey: wikiKeys.index ({ }, locale), + queryFn: () => fetchWikiPages ({ }, locale) }) } } const effectiveTitle = wikiPage?.title ?? title await qc.prefetchQuery ({ - queryKey: tagsKeys.show (effectiveTitle), - queryFn: () => fetchTagByName (effectiveTitle) }) + queryKey: tagsKeys.show (effectiveTitle, locale), + queryFn: () => fetchTagByName (effectiveTitle, locale) }) if (version) return @@ -143,12 +151,12 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => { updatedTo: '', order: 'original_created_at:desc' } as const await qc.prefetchQuery ({ - queryKey: postsKeys.index (p), - queryFn: () => fetchPosts (p) }) + queryKey: postsKeys.index (p, locale), + queryFn: () => fetchPosts (p, locale) }) } -const prefetchPostsIndex: Prefetcher = async (qc, url) => { +const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => { const tags = url.searchParams.get ('tags') ?? '' const qURL = url.searchParams.get ('url') ?? '' const title = url.searchParams.get ('title') ?? '' @@ -169,12 +177,12 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => { updatedFrom, updatedTo, order } await qc.prefetchQuery ({ - queryKey: postsKeys.index (keys), - queryFn: () => fetchPosts (keys) }) + queryKey: postsKeys.index (keys, locale), + queryFn: () => fetchPosts (keys, locale) }) } -const prefetchPostShow: Prefetcher = async (qc, url) => { +const prefetchPostShow: Prefetcher = async (qc, url, locale) => { const m = mPost (url.pathname) if (!(m)) return @@ -182,12 +190,12 @@ const prefetchPostShow: Prefetcher = async (qc, url) => { const { id } = m.params await qc.prefetchQuery ({ - queryKey: postsKeys.show (id), - queryFn: () => fetchPost (id) }) + queryKey: postsKeys.show (id, locale), + queryFn: () => fetchPost (id, locale) }) } -const prefetchPostChanges: Prefetcher = async (qc, url) => { +const prefetchPostChanges: Prefetcher = async (qc, url, locale) => { const id = url.searchParams.get ('id') const tag = url.searchParams.get ('tag') const externalTag = url.searchParams.get ('external_tag') @@ -197,23 +205,23 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => { if (tag) { await qc.prefetchQuery ({ - queryKey: tagsKeys.show (tag), - queryFn: () => fetchTag (tag) }) + queryKey: tagsKeys.show (tag, locale), + queryFn: () => fetchTag (tag, locale) }) } await qc.prefetchQuery ({ queryKey: postsKeys.changes ({ ...(id && { id }), ...(tag && { tag }), ...(externalTag && { externalTag }), - page, limit }), + page, limit }, locale), queryFn: () => fetchPostChanges ({ ...(id && { id }), ...(tag && { tag }), ...(externalTag && { externalTag }), - page, limit }) }) + page, limit }, locale) }) } -const prefetchTagsIndex: Prefetcher = async (qc, url) => { +const prefetchTagsIndex: Prefetcher = async (qc, url, locale) => { const postRaw = url.searchParams.get ('post') const post = postRaw ? Number (postRaw) : null const name = url.searchParams.get ('name') ?? '' @@ -237,12 +245,12 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { updatedFrom, updatedTo, deprecated, page, limit, order } await qc.prefetchQuery ({ - queryKey: tagsKeys.index (keys), - queryFn: () => fetchTags (keys) }) + queryKey: tagsKeys.index (keys, locale), + queryFn: () => fetchTags (keys, locale) }) } -const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => { +const prefetchNicoTagsIndex: Prefetcher = async (qc, url, locale) => { const keys = { name: url.searchParams.get ('name') ?? '', linkedTag: url.searchParams.get ('linked_tag') ?? '', @@ -255,12 +263,12 @@ const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => { | 'updated_at:asc' | 'updated_at:desc' } await qc.prefetchQuery ({ - queryKey: tagsKeys.nicoIndex (keys), - queryFn: () => fetchNicoTags (keys) }) + queryKey: tagsKeys.nicoIndex (keys, locale), + queryFn: () => fetchNicoTags (keys, locale) }) } -const prefetchTagShow: Prefetcher = async (qc, url) => { +const prefetchTagShow: Prefetcher = async (qc, url, locale) => { const m = mTag (url.pathname) if (!(m)) return @@ -268,19 +276,19 @@ const prefetchTagShow: Prefetcher = async (qc, url) => { const { id } = m.params await qc.prefetchQuery ({ - queryKey: tagsKeys.show (id), - queryFn: () => fetchTag (id) }) + queryKey: tagsKeys.show (id, locale), + queryFn: () => fetchTag (id, locale) }) } -const prefetchTagChanges: Prefetcher = async (qc, url) => { +const prefetchTagChanges: Prefetcher = async (qc, url, locale) => { const id = url.searchParams.get ('id') const page = Number (url.searchParams.get ('page') || 1) const limit = Number (url.searchParams.get ('limit') || 20) await qc.prefetchQuery ({ - queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), - queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) + queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale), + queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) }) } @@ -313,9 +321,14 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise => { const u = new URL (urlLike, location.origin) + if (u.origin !== location.origin) + return + + const locale = localeFromPath (u.pathname) ?? 'ja' + u.pathname = unlocalisedPath (u.pathname) const r = routePrefetchers.find (x => x.test (u)) if (!(r)) return - await r.run (qc, u) + await r.run (qc, u, locale) } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 4105ee6..8daef0f 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,3 +1,4 @@ +import type { LocaleCode } from '@/lib/locales' import type { FetchNicoTagsParams, FetchMaterialsParams, @@ -8,59 +9,125 @@ import type { export const postsKeys = { root: ['posts'] as const, - index: (p: FetchPostsParams) => ['posts', 'index', p] as const, - show: (id: string) => ['posts', id] as const, - related: (id: string) => ['related', id] as const, - changes: (p: { post?: string + index: ( + p: FetchPostsParams, + locale: LocaleCode = 'ja', +) => ['posts', locale, 'index', p] as const, + show: ( + id: string, + locale: LocaleCode = 'ja', +) => ['posts', locale, id] as const, + related: ( + id: string, + locale: LocaleCode = 'ja', +) => ['related', locale, id] as const, + changes: ( + p: { post?: string tag?: string externalTag?: string page: number - limit: number }) => - ['posts', 'changes', p] as const } + limit: number }, + locale: LocaleCode = 'ja', +) => + ['posts', locale, 'changes', p] as const } export const gekanatorKeys = { root: ['gekanator'] as const, - posts: () => ['gekanator', 'posts'] as const, - questions: () => ['gekanator', 'questions'] as const, - extraQuestions: (gameId: number, nonce: string) => - ['gekanator', 'games', gameId, 'extra-questions', nonce] as const } + posts: ( + locale: LocaleCode = 'ja', +) => ['gekanator', locale, 'posts'] as const, + questions: ( + locale: LocaleCode = 'ja', +) => ['gekanator', locale, 'questions'] as const, + extraQuestions: ( + gameId: number, + nonce: string, + locale: LocaleCode = 'ja', +) => + ['gekanator', locale, 'games', gameId, 'extra-questions', nonce] as const } export const tagsKeys = { root: ['tags'] as const, - index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + index: ( + p: FetchTagsParams, + locale: LocaleCode = 'ja', +) => ['tags', locale, 'index', p] as const, nicoRoot: ['tags', 'nico'] as const, - nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const, - externalShow: (id: string) => ['tags', 'nico', id] as const, - show: (name: string) => ['tags', name] as const, - changes: (p: { id?: string; page: number; limit: number }) => - ['tags', 'changes', p] as const, - deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } + nicoIndex: ( + p: FetchNicoTagsParams, + locale: LocaleCode = 'ja', +) => ['tags', locale, 'nico', 'index', p] as const, + externalShow: ( + id: string, + locale: LocaleCode = 'ja', +) => ['tags', locale, 'nico', id] as const, + show: ( + name: string, + locale: LocaleCode = 'ja', +) => ['tags', locale, name] as const, + changes: ( + p: { id?: string; page: number; limit: number }, + locale: LocaleCode = 'ja', +) => + ['tags', locale, 'changes', p] as const, + deerjikists: ( + id: string, + locale: LocaleCode = 'ja', +) => ['tags', locale, 'deerjikists', id] as const } export const materialsKeys = { root: ['materials'] as const, - index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const, - changes: (p: { + index: ( + p: FetchMaterialsParams, + locale: LocaleCode = 'ja', +) => ['materials', locale, 'index', p] as const, + changes: ( + p: { materialId?: string tag?: string eventType?: string page: number limit: number - }) => ['materials', 'changes', p] as const, - byTagName: (name: string, materialFilter: MaterialFilter) => - ['materials', 'tag', name, materialFilter] as const, - show: (id: string) => ['materials', id] as const, - suppressions: () => ['materials', 'suppressions'] as const, - tree: (p: { + }, + locale: LocaleCode = 'ja', +) => ['materials', locale, 'changes', p] as const, + byTagName: ( + name: string, + materialFilter: MaterialFilter, + locale: LocaleCode = 'ja', +) => + ['materials', locale, 'tag', name, materialFilter] as const, + show: ( + id: string, + locale: LocaleCode = 'ja', +) => ['materials', locale, id] as const, + suppressions: ( + locale: LocaleCode = 'ja', +) => ['materials', locale, 'suppressions'] as const, + tree: ( + p: { parentId?: number | null materialFilter: MaterialFilter - }) => ['materials', 'tree', p] as const, - unclassified: (p: { page?: number; limit?: number } = { }) => - ['materials', 'unclassified', p] as const} + }, + locale: LocaleCode = 'ja', +) => ['materials', locale, 'tree', p] as const, + unclassified: ( + p: { page?: number; limit?: number } = { }, + locale: LocaleCode = 'ja', +) => + ['materials', locale, 'unclassified', p] as const} export const wikiKeys = { root: ['wiki'] as const, - index: (p: { title?: string }) => ['wiki', 'index', p] as const, - show: (title: string, p: { version?: string }) => ['wiki', title, p] as const } + index: ( + p: { title?: string }, + locale: LocaleCode = 'ja', +) => ['wiki', locale, 'index', p] as const, + show: ( + title: string, + p: { version?: string }, + locale: LocaleCode = 'ja', +) => ['wiki', locale, title, p] as const } export const helpKeys = { root: ['help'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index bc16d19..6096b49 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -2,6 +2,7 @@ import { isAxiosError } from 'axios' import { apiGet } from '@/lib/api' +import type { LocaleCode } from '@/lib/locales' import type { Deerjikist, FetchNicoTagsParams, FetchTagsParams, @@ -13,9 +14,11 @@ import type { Deerjikist, export const fetchTags = async ( { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, updatedFrom, updatedTo, deprecated, - page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[] + page, limit, order }: FetchTagsParams, + locale: LocaleCode = 'ja', +): Promise<{ tags: Tag[] count: number }> => - await apiGet ('/tags', { params: { + await apiGet ('/tags', { locale, params: { ...(post != null && { post }), ...(name && { name }), ...(category && { category }), @@ -32,9 +35,11 @@ export const fetchTags = async ( export const fetchNicoTags = async ( - { name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[] + { name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams, + locale: LocaleCode = 'ja', +): Promise<{ tags: NicoTag[] count: number }> => - await apiGet ('/tags/nico', { params: { + await apiGet ('/tags/nico', { locale, params: { page, limit, name, @@ -43,10 +48,13 @@ export const fetchNicoTags = async ( order } }) -export const fetchTag = async (id: string): Promise => { +export const fetchTag = async ( + id: string, + locale: LocaleCode = 'ja', +): Promise => { try { - return await apiGet (`/tags/${ id }`) + return await apiGet (`/tags/${ id }`, { locale }) } catch { @@ -55,10 +63,13 @@ export const fetchTag = async (id: string): Promise => { } -export const fetchExternalTag = async (id: string): Promise => { +export const fetchExternalTag = async ( + id: string, + locale: LocaleCode = 'ja', +): Promise => { try { - return await apiGet (`/tags/nico/${ id }`) + return await apiGet (`/tags/nico/${ id }`, { locale }) } catch { @@ -67,10 +78,13 @@ export const fetchExternalTag = async (id: string): Promise => { } -export const fetchTagByName = async (name: string): Promise => { +export const fetchTagByName = async ( + name: string, + locale: LocaleCode = 'ja', +): Promise => { try { - return await apiGet (`/tags/name/${ encodeURIComponent (name) }`) + return await apiGet (`/tags/name/${ encodeURIComponent (name) }`, { locale }) } catch (error) { @@ -84,12 +98,16 @@ export const fetchTagChanges = async ( { id, page, limit }: { id?: string page: number - limit: number }): Promise<{ + limit: number }, + locale: LocaleCode = 'ja', +): Promise<{ versions: TagVersion[] count: number }> => - await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) + await apiGet ('/tags/versions', { locale, params: { ...(id && { id }), page, limit } }) export const fetchDeerjikistsByTag = async ( - id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> => - await apiGet (`/tags/${ id }/deerjikists`) + id: string, + locale: LocaleCode = 'ja', +): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> => + await apiGet (`/tags/${ id }/deerjikists`, { locale }) diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index d6e955f..6cd80d2 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -7,8 +7,9 @@ import { useRef, useState, } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' +import { useLocaleNavigate } from '@/lib/useLocale' import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog' import { DEFAULT_KEY_BINDINGS, @@ -95,7 +96,7 @@ const focusSearchTarget = (): void => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const [keyboardSettings, setKeyboardSettingsState] = useState (() => getClientKeyboardSettings ()) diff --git a/frontend/src/lib/useLocale.ts b/frontend/src/lib/useLocale.ts new file mode 100644 index 0000000..fb6ee21 --- /dev/null +++ b/frontend/src/lib/useLocale.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import { useLocation, useNavigate, useParams } from 'react-router-dom' + +import { isLocaleCode, localeFromPath, localiseTo } from '@/lib/locales' + +import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom' + +import type { LocaleCode } from '@/lib/locales' + + +export const useLocale = (): LocaleCode => { + const { locale } = useParams () + const location = useLocation () + return isLocaleCode (locale) ? locale : localeFromPath (location.pathname) ?? 'ja' +} + + +export const useLocaleNavigate = (): NavigateFunction => { + const navigate = useNavigate () + const locale = useLocale () + return useCallback ((to: To | number, options?: NavigateOptions) => { + if (typeof to === 'number') + navigate (to) + else + navigate (localiseTo (to, locale), options) + }, [locale, navigate]) +} diff --git a/frontend/src/lib/wiki.ts b/frontend/src/lib/wiki.ts index 3ffe67d..1ea7392 100644 --- a/frontend/src/lib/wiki.ts +++ b/frontend/src/lib/wiki.ts @@ -2,26 +2,33 @@ import { isAxiosError } from 'axios' import { apiGet } from '@/lib/api' +import type { LocaleCode } from '@/lib/locales' import type { WikiPage } from '@/types' export const fetchWikiPages = async ( - { title }: { title?: string }): Promise => - await apiGet ('/wiki', { params: { title } }) + { title }: { title?: string }, + locale: LocaleCode = 'ja', +): Promise => + await apiGet ('/wiki', { locale, params: { title } }) export const fetchWikiPage = async ( id: string, - { version }: { version?: string }): Promise => - await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } }) + { version }: { version?: string }, + locale: LocaleCode = 'ja', +): Promise => + await apiGet (`/wiki/${ id }`, { locale, params: version ? { version } : { } }) export const fetchWikiPageByTitle = async ( title: string, - { version }: { version?: string }): Promise => { + { version }: { version?: string }, + locale: LocaleCode = 'ja', +): Promise => { try { - return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) + return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } }) } catch (error) { diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 181bb0c..d6d7009 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' +import { useLocale } from '@/lib/useLocale' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' @@ -3678,6 +3679,7 @@ const expectedAnswerFor = ( const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { + const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const storedGame = useMemo (loadStoredGame, []) @@ -3771,8 +3773,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ - queryKey: gekanatorKeys.posts (), - queryFn: fetchGekanatorPosts, + queryKey: gekanatorKeys.posts (locale), + queryFn: () => fetchGekanatorPosts (locale), refetchOnWindowFocus: false }) const { data: acceptedQuestions = [], @@ -3780,8 +3782,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { isLoading: acceptedQuestionsLoading, error: acceptedQuestionsError } = useQuery ({ - queryKey: gekanatorKeys.questions (), - queryFn: fetchGekanatorQuestions, + queryKey: gekanatorKeys.questions (locale), + queryFn: () => fetchGekanatorQuestions (locale), select: questions => questions.map (restoreGekanatorQuestion), refetchOnWindowFocus: false }) const materialIndex = useMemo (() => buildMaterialIndex (posts), [posts]) @@ -4103,7 +4105,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const mascotAsset = mascotAssetByState[mascot] const mascotAlt = mascotAltByState[mascot] const saveMutation = useMutation ({ - mutationFn: saveGekanatorGame, + mutationFn: (variables: Parameters[0]) => saveGekanatorGame (variables, locale), onSuccess: (data, variables) => { setRecentGames (storeRecentGameSummary ({ correctPostId: variables.correctPostId, @@ -4114,9 +4116,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { setLearnedExampleCount (data.learnedExampleCount) setResultWon (variables.guessedPostId === variables.correctPostId)}}) const questionSuggestionMutation = useMutation ({ - mutationFn: saveGekanatorQuestionSuggestion, + mutationFn: (variables: Parameters[0]) => saveGekanatorQuestionSuggestion (variables, locale), onSuccess: async data => { - await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) + await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions (locale) }) setQuestionSuggestionCount (data.count) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') @@ -4124,9 +4126,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes')}}) const extraQuestionAnswersMutation = useMutation ({ - mutationFn: saveGekanatorExtraQuestionAnswers, + mutationFn: (variables: Parameters[0]) => saveGekanatorExtraQuestionAnswers (variables, locale), onSuccess: async () => { - await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) + await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions (locale) }) setExtraQuestionState ('saved') setPhase ('end')}}) @@ -4784,8 +4786,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { try { const questions = await queryClient.fetchQuery ({ - queryKey: gekanatorKeys.extraQuestions (gameId, nonce), - queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) }) + queryKey: gekanatorKeys.extraQuestions (gameId, nonce, locale), + queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce, locale) }) setExtraQuestions (questions.slice (0, 2)) setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty') } diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx index e3daec4..b383a0e 100644 --- a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' +import { useLocale } from '@/lib/useLocale' import TagLink from '@/components/TagLink' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' @@ -24,12 +25,13 @@ type DeerjikistFormField = const DeerjikistDetailPage: FC = () => { + const locale = useLocale () const { id } = useParams () const tagId = String (id ?? '') - const tagKey = tagsKeys.deerjikists (tagId) + const tagKey = tagsKeys.deerjikists (tagId, locale) const { data: qData, isLoading: loading } = - useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId) }) + useQuery ({ queryKey: tagKey, queryFn: () => fetchDeerjikistsByTag (tagId, locale) }) const tag = qData?.tag const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData]) @@ -49,7 +51,7 @@ const DeerjikistDetailPage: FC = () => { { setDisabled (true) - setData (await apiPut (`/tags/${ id }/deerjikists`, data)) + setData (await apiPut (`/tags/${ id }/deerjikists`, data, { locale })) qc.invalidateQueries ({ queryKey: tagsKeys.root }) toast ({ description: '更新しました.' }) diff --git a/frontend/src/pages/help/HelpChangesPage.tsx b/frontend/src/pages/help/HelpChangesPage.tsx index 1fac81e..5dffddf 100644 --- a/frontend/src/pages/help/HelpChangesPage.tsx +++ b/frontend/src/pages/help/HelpChangesPage.tsx @@ -9,6 +9,8 @@ import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { getHelpChanges, helpEditionDate } from '@/lib/help' +import { localeLanguage } from '@/lib/locales' +import { useLocale } from '@/lib/useLocale' import { helpKeys } from '@/lib/queryKeys' import { LIST_LIMIT_OPTIONS } from '@/lib/settings' import { dateString } from '@/lib/utils' @@ -17,6 +19,7 @@ import type { FC } from 'react' const HelpChangesPage: FC = () => { + const language = localeLanguage (useLocale ()) const [searchParams, setSearchParams] = useSearchParams () const identifier = searchParams.get ('identifier') ?? '' const pageValue = searchParams.get ('page') ?? '1' @@ -27,8 +30,8 @@ const HelpChangesPage: FC = () => { const limit = LIST_LIMIT_OPTIONS.find ( value => String (value) === searchParams.get ('limit')) ?? 20 const { data: changes = [], isLoading, error } = useQuery ({ - queryKey: helpKeys.changes ('ja'), - queryFn: () => getHelpChanges ('ja'), + queryKey: helpKeys.changes (language), + queryFn: () => getHelpChanges (language), staleTime: Infinity }) const results = useMemo (() => identifier diff --git a/frontend/src/pages/help/HelpDetailPage.tsx b/frontend/src/pages/help/HelpDetailPage.tsx index 8e3d621..11f234c 100644 --- a/frontend/src/pages/help/HelpDetailPage.tsx +++ b/frontend/src/pages/help/HelpDetailPage.tsx @@ -8,7 +8,10 @@ import { SITE_TITLE } from '@/config' import { getHelpArticle, getHelpEditionContent, helpEditionDate, - parseHelpEdition } from '@/lib/help' + parseHelpEdition, + resolveHelpLanguage } from '@/lib/help' +import { localeLanguage } from '@/lib/locales' +import { useLocale } from '@/lib/useLocale' import { dateString } from '@/lib/utils' import { helpKeys } from '@/lib/queryKeys' import NotFound from '@/pages/NotFound' @@ -17,17 +20,17 @@ import type { FC } from 'react' const HelpDetailPage: FC = () => { + const locale = useLocale () const { identifier = '' } = useParams () const location = useLocation () - // Locale 対応が入るまでは ja 固定 - const language = 'ja' - const { data: article, isLoading: articleLoading } = useQuery ({ queryKey: helpKeys.article (identifier), queryFn: () => getHelpArticle (identifier), staleTime: Infinity }) + const language = resolveHelpLanguage (article, localeLanguage (locale)) + const query = new URLSearchParams (location.search) const requestedEdition = query.get ('edition') @@ -96,7 +99,7 @@ const HelpDetailPage: FC = () => { : '(最新)'}
)} -
+
{Content ? :
Loading...
}
) diff --git a/frontend/src/pages/help/HelpDiffPage.tsx b/frontend/src/pages/help/HelpDiffPage.tsx index 24d9304..53ef0b4 100644 --- a/frontend/src/pages/help/HelpDiffPage.tsx +++ b/frontend/src/pages/help/HelpDiffPage.tsx @@ -6,7 +6,9 @@ import PrefetchLink from '@/components/PrefetchLink' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' -import { getHelpDiff } from '@/lib/help' +import { getHelpArticle, getHelpDiff, resolveHelpLanguage } from '@/lib/help' +import { localeLanguage } from '@/lib/locales' +import { useLocale } from '@/lib/useLocale' import { helpKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import NotFound from '@/pages/NotFound' @@ -17,17 +19,23 @@ const markers = { added: '+ ', removed: '- ', unchanged: ' ' } const HelpDiffPage: FC = () => { + const locale = useLocale () const { identifier = '' } = useParams () const [searchParams] = useSearchParams () const from = searchParams.get ('from') ?? '' const to = searchParams.get ('to') ?? '' + const { data: article } = useQuery ({ + queryKey: helpKeys.article (identifier), + queryFn: () => getHelpArticle (identifier), + staleTime: Infinity }) + const language = resolveHelpLanguage (article, localeLanguage (locale)) const validParams = Boolean (identifier && from && to) && searchParams.getAll ('from').length === 1 && searchParams.getAll ('to').length === 1 const { data: diff, isLoading, isError } = useQuery ({ - enabled: validParams, - queryKey: helpKeys.diff (identifier, 'ja', from, to), - queryFn: () => getHelpDiff (identifier, 'ja', from, to), + enabled: validParams && article != null, + queryKey: helpKeys.diff (identifier, language, from, to), + queryFn: () => getHelpDiff (identifier, language, from, to), staleTime: Infinity }) if (!(validParams) || isError || (!(isLoading) && diff == null)) @@ -55,7 +63,7 @@ const HelpDiffPage: FC = () => { {diff.to.title} ({to}) -
+	      
 		
 		  {diff.lines.map ((line, index) => {
 		    const colour = cn (
diff --git a/frontend/src/pages/help/HelpListPage.tsx b/frontend/src/pages/help/HelpListPage.tsx
index bf0341c..0bbf5c1 100644
--- a/frontend/src/pages/help/HelpListPage.tsx
+++ b/frontend/src/pages/help/HelpListPage.tsx
@@ -15,6 +15,8 @@ import { compareHelpEditions,
 	 getHelpSearchEntries,
 	 helpEditionDate,
 	 normaliseHelpSearchText } from '@/lib/help'
+import { localeLanguage } from '@/lib/locales'
+import { useLocale } from '@/lib/useLocale'
 import { helpKeys } from '@/lib/queryKeys'
 import { LIST_LIMIT_OPTIONS } from '@/lib/settings'
 import { dateString, inputClass } from '@/lib/utils'
@@ -30,6 +32,7 @@ const defaultDirection: Record = {
 
 
 const HelpSearchPage: FC = () => {
+  const language = localeLanguage (useLocale ())
   const [searchParams, setSearchParams] = useSearchParams ()
   const pageValue = searchParams.get ('page') ?? '1'
   const pageNumber = Number (pageValue)
@@ -49,8 +52,8 @@ const HelpSearchPage: FC = () => {
   const [title, setTitle] = useState (conditions.title)
   const [text, setText] = useState (conditions.text)
   const { data: entries = [], isLoading: loading, error } = useQuery ({
-      queryKey: helpKeys.index ('ja'),
-      queryFn: () => getHelpSearchEntries ('ja'),
+      queryKey: helpKeys.index (language),
+      queryFn: () => getHelpSearchEntries (language),
       staleTime: Infinity })
 
   useEffect (() => {
diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx
index 2be6299..fba4219 100644
--- a/frontend/src/pages/materials/MaterialDetailPage.tsx
+++ b/frontend/src/pages/materials/MaterialDetailPage.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
 import { useParams } from 'react-router-dom'
 
+import { useLocale } from '@/lib/useLocale'
 import TagLink from '@/components/TagLink'
 import WikiBody from '@/components/WikiBody'
 import FieldError from '@/components/common/FieldError'
@@ -28,6 +29,7 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
 
 
 const MaterialDetailPage: FC = () => {
+  const locale = useLocale ()
   const { id } = useParams ()
   const qc = useQueryClient ()
 
@@ -40,8 +42,8 @@ const MaterialDetailPage: FC = () => {
       useValidationErrors ()
 
   const { data: material, isError, isLoading } = useQuery ({
-      queryKey: materialsKeys.show (id ?? ''),
-      queryFn:  () => fetchMaterial (id ?? ''),
+      queryKey: materialsKeys.show (id ?? '', locale),
+      queryFn:  () => fetchMaterial (id ?? '', locale),
       enabled:  id != null})
   const materialTitle = material
       ? material.tag?.name ?? `素材 #${ material.id }`
@@ -76,10 +78,10 @@ const MaterialDetailPage: FC = () => {
 	  formData.append ('url', url)
 	formData.append ('export_paths[legacy_drive]', exportPath)
 
-	return await updateMaterial (id ?? '', formData)
+	return await updateMaterial (id ?? '', formData, locale)
       },
       onSuccess: async data => {
-	qc.setQueryData (materialsKeys.show (id ?? ''), data)
+	qc.setQueryData (materialsKeys.show (id ?? '', locale), data)
 	await invalidateMaterialQueries ()
 	toast ({ title: '更新成功!' })
       },
diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx
index 920fbad..b8e2308 100644
--- a/frontend/src/pages/materials/MaterialHistoryPage.tsx
+++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx
@@ -1,8 +1,9 @@
 import { useQuery } from '@tanstack/react-query'
 import { useEffect, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
-import { useLocation, useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router-dom'
 
+import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
 import PrefetchLink from '@/components/PrefetchLink'
 import FormField from '@/components/common/FormField'
 import PageTitle from '@/components/common/PageTitle'
@@ -19,10 +20,11 @@ import type { FC, FormEvent } from 'react'
 
 
 const MaterialHistoryPage: FC = () => {
+  const locale = useLocale ()
   const behaviourSettings = useClientBehaviourSettings ()
   const animationMode = behaviourSettings.animation ?? 'normal'
   const location = useLocation ()
-  const navigate = useNavigate ()
+  const navigate = useLocaleNavigate ()
   const query = new URLSearchParams (location.search)
 
   const materialId = query.get ('material_id') ?? ''
@@ -47,13 +49,13 @@ const MaterialHistoryPage: FC = () => {
 	  ...(tag && { tag }),
 	  ...(eventType && { eventType }),
 	  page,
-	  limit }),
+	  limit }, locale),
       queryFn: () => fetchMaterialChanges ({
 	  ...(materialId && { materialId }),
 	  ...(tag && { tag }),
 	  ...(eventType && { eventType }),
 	  page,
-	  limit }) })
+	  limit }, locale) })
 
   const versions = data?.versions ?? []
   const totalPages = data ? Math.ceil (data.count / limit) : 0
diff --git a/frontend/src/pages/materials/MaterialListPage.tsx b/frontend/src/pages/materials/MaterialListPage.tsx
index 7a5b718..a8634aa 100644
--- a/frontend/src/pages/materials/MaterialListPage.tsx
+++ b/frontend/src/pages/materials/MaterialListPage.tsx
@@ -1,8 +1,9 @@
 import { useQuery } from '@tanstack/react-query'
 import { useEffect, useMemo, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
-import { useLocation, useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router-dom'
 
+import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
 import nikumaru from '@/assets/fonts/nikumaru.otf'
 import PrefetchLink from '@/components/PrefetchLink'
 import DateTimeField from '@/components/common/DateTimeField'
@@ -210,8 +211,9 @@ const GroupHeading: FC<{
 
 
 const MaterialListPage: FC = () => {
+  const locale = useLocale ()
   const location = useLocation ()
-  const navigate = useNavigate ()
+  const navigate = useLocaleNavigate ()
   const query = useMemo (() => new URLSearchParams (location.search), [location.search])
   const materialFilter = parseMaterialFilter (query.get ('material_filter'), 'present')
 
@@ -277,8 +279,8 @@ const MaterialListPage: FC = () => {
       page,
       limit}
   const { data, isLoading, isError } = useQuery ({
-      queryKey: materialsKeys.index (keys),
-      queryFn:  () => fetchMaterials (keys)})
+      queryKey: materialsKeys.index (keys, locale),
+      queryFn:  () => fetchMaterials (keys, locale)})
   const tagScope = data?.tagScope ?? null
   const materials = data?.materials ?? []
   const groups = data?.groups ?? []
diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx
index ef8c6a9..3c60339 100644
--- a/frontend/src/pages/materials/MaterialNewPage.tsx
+++ b/frontend/src/pages/materials/MaterialNewPage.tsx
@@ -1,8 +1,9 @@
 import { useMutation, useQueryClient } from '@tanstack/react-query'
 import { useState } from 'react'
 import { Helmet } from 'react-helmet-async'
-import { useLocation, useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router-dom'
 
+import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
 import FieldError from '@/components/common/FieldError'
 import Form from '@/components/common/Form'
 import FormField from '@/components/common/FormField'
@@ -13,6 +14,7 @@ import { Button } from '@/components/ui/button'
 import { toast } from '@/components/ui/use-toast'
 import { SITE_TITLE } from '@/config'
 import { createMaterial } from '@/lib/materials'
+import { unlocalisedPath } from '@/lib/locales'
 import { materialsKeys } from '@/lib/queryKeys'
 import { inputClass } from '@/lib/utils'
 import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -23,14 +25,16 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
 
 
 const MaterialNewPage: FC = () => {
+  const locale = useLocale ()
   const qc = useQueryClient ()
   const location = useLocation ()
   const query = new URLSearchParams (location.search)
   const tagQuery = query.get ('tag') ?? ''
   const returnToQuery = query.get ('return_to') ?? ''
-  const safeReturnTo = returnToQuery.startsWith ('/materials') ? returnToQuery : ''
+  const safeReturnTo =
+      unlocalisedPath (returnToQuery).startsWith ('/materials') ? returnToQuery : ''
 
-  const navigate = useNavigate ()
+  const navigate = useLocaleNavigate ()
 
   const [file, setFile] = useState (null)
   const [filePreview, setFilePreview] = useState ('')
@@ -51,7 +55,7 @@ const MaterialNewPage: FC = () => {
 	  formData.append ('url', url)
 	formData.append ('export_paths[legacy_drive]', exportPath)
 
-	return await createMaterial (formData)
+	return await createMaterial (formData, locale)
       },
       onSuccess: async () => {
 	await qc.invalidateQueries ({ queryKey: materialsKeys.root })
diff --git a/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx b/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx
index f398bbc..b155de9 100644
--- a/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx
+++ b/frontend/src/pages/materials/MaterialSyncSuppressionsPage.tsx
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
 import { useState } from 'react'
 import { Helmet } from 'react-helmet-async'
 
+import { useLocale } from '@/lib/useLocale'
 import FormField from '@/components/common/FormField'
 import PageTitle from '@/components/common/PageTitle'
 import MainArea from '@/components/layout/MainArea'
@@ -52,6 +53,7 @@ const reasonName = (reason: string): string =>
 
 
 const MaterialSyncSuppressionsPage: FC = () => {
+  const locale = useLocale ()
   const qc = useQueryClient ()
   const [sourceKind, setSourceKind] =
       useState ('uri')
@@ -61,8 +63,8 @@ const MaterialSyncSuppressionsPage: FC = () => {
   const [reason, setReason] = useState (REASONS[0])
 
   const { data, isError, isLoading } = useQuery ({
-      queryKey: materialsKeys.suppressions (),
-      queryFn:  fetchMaterialSyncSuppressions})
+      queryKey: materialsKeys.suppressions (locale),
+      queryFn:  () => fetchMaterialSyncSuppressions (locale)})
 
   const createMutation = useMutation ({
       mutationFn: async () =>
@@ -71,9 +73,9 @@ const MaterialSyncSuppressionsPage: FC = () => {
 	      sourceUri,
 	      drivePath,
 	      driveFileId,
-	      reason}),
+	      reason}, locale),
       onSuccess: async () => {
-	await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions () })
+	await qc.invalidateQueries ({ queryKey: materialsKeys.suppressions (locale) })
 	await qc.invalidateQueries ({ queryKey: materialsKeys.root })
 	setSourceUri ('')
 	setDrivePath ('')
diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx
index 01957df..9431c22 100644
--- a/frontend/src/pages/posts/PostDetailPage.tsx
+++ b/frontend/src/pages/posts/PostDetailPage.tsx
@@ -3,6 +3,7 @@ import { motion } from 'framer-motion'
 import { useEffect, useRef, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
 import { useParams } from 'react-router-dom'
+import { useLocale } from '@/lib/useLocale'
 import PostEditForm from '@/components/PostEditForm'
 import PostEmbed from '@/components/PostEmbed'
 import PostList from '@/components/PostList'
@@ -29,6 +30,7 @@ type Props = { user: User | null }
 
 
 const PostDetailPage: FC = ({ user }) => {
+  const locale = useLocale ()
   const editable = canEditContent (user)
   const behaviourSettings = useClientBehaviourSettings ()
   const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
@@ -42,12 +44,12 @@ const PostDetailPage: FC = ({ user }) => {
 	? { duration: .08, ease: 'linear' as const }
 	: { duration: .2, ease: 'easeOut' as const }
   const postId = String (id ?? '')
-  const postKey = postsKeys.show (postId)
+  const postKey = postsKeys.show (postId, locale)
 
   const { data: post, isError: errorFlg, error } = useQuery ({
       enabled:  Boolean (id),
       queryKey: postKey,
-      queryFn:  () => fetchPost (postId) })
+      queryFn:  () => fetchPost (postId, locale) })
 
   const qc = useQueryClient ()
 
@@ -181,7 +183,7 @@ const PostDetailPage: FC = ({ user }) => {
 			 {
-			      qc.setQueryData (postsKeys.show (postId),
+			      qc.setQueryData (postsKeys.show (postId, locale),
 					       (prev: Post | undefined) => newPost ?? prev)
 			      qc.invalidateQueries ({ queryKey: postsKeys.root })
 			      qc.invalidateQueries ({ queryKey: tagsKeys.root })
diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx
index 446dc38..3a9a5e5 100644
--- a/frontend/src/pages/posts/PostHistoryPage.tsx
+++ b/frontend/src/pages/posts/PostHistoryPage.tsx
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
 import { Helmet } from 'react-helmet-async'
 import { useLocation } from 'react-router-dom'
 
+import { useLocale } from '@/lib/useLocale'
 import TagLink from '@/components/TagLink'
 import PrefetchLink from '@/components/PrefetchLink'
 import PageTitle from '@/components/common/PageTitle'
@@ -39,6 +40,7 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
 
 
 const PostHistoryPage: FC = () => {
+  const locale = useLocale ()
   const dialogue = useDialogue ()
   const behaviourSettings = useClientBehaviourSettings ()
   const animationMode = behaviourSettings.animation ?? 'normal'
@@ -61,25 +63,25 @@ const PostHistoryPage: FC = () => {
   const tagQueryId = tagId ?? ''
   const { data: tag } = useQuery ({
       enabled:  Boolean (tagId),
-      queryKey: tagsKeys.show (tagQueryId),
-      queryFn:  () => fetchTag (tagQueryId) })
+      queryKey: tagsKeys.show (tagQueryId, locale),
+      queryFn:  () => fetchTag (tagQueryId, locale) })
 
   const externalTagQueryId = externalTagId ?? ''
   const { data: externalTag } = useQuery ({
       enabled:  Boolean (externalTagId),
-      queryKey: tagsKeys.externalShow (externalTagQueryId),
-      queryFn:  () => fetchExternalTag (externalTagQueryId) })
+      queryKey: tagsKeys.externalShow (externalTagQueryId, locale),
+      queryFn:  () => fetchExternalTag (externalTagQueryId, locale) })
 
   const { data, isLoading: loading } = useQuery ({
       queryKey: postsKeys.changes ({ ...(id && { post: id }),
 				     ...(tagId && { tag: tagId }),
 				     ...(externalTagId && { externalTag: externalTagId }),
-				     page, limit }),
+				     page, limit }, locale),
       queryFn:  () => fetchPostChanges ({ ...(id && { post: id }),
 					  ...(tagId && { tag: tagId }),
 					  ...(externalTagId && {
 					    externalTag: externalTagId }),
-					  page, limit }) })
+					  page, limit }, locale) })
   const changes = data?.versions ?? []
   const totalPages = data ? Math.ceil (data.count / limit) : 0
   const displayedTag = externalTag ?? tag
@@ -121,7 +123,7 @@ const PostHistoryPage: FC = () => {
       const originalCreatedBefore = change.originalCreatedBefore.current
       await updatePost ({ id, title, tags, duration, parentPostIds,
 			  originalCreatedFrom, originalCreatedBefore },
-		        { force: true })
+		        { force: true }, locale)
 
       qc.invalidateQueries ({ queryKey: postsKeys.root })
       qc.invalidateQueries ({ queryKey: tagsKeys.root })
diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx
index 9dcc181..6c636bc 100644
--- a/frontend/src/pages/posts/PostImportReviewPage.tsx
+++ b/frontend/src/pages/posts/PostImportReviewPage.tsx
@@ -2,8 +2,9 @@ import { AnimatePresence, motion } from 'framer-motion'
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
 import { ChevronRight } from 'lucide-react'
-import { useLocation, useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router-dom'
 
+import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
 import PageTitle from '@/components/common/PageTitle'
 import MainArea from '@/components/layout/MainArea'
 import PrefetchLink from '@/components/PrefetchLink'
@@ -595,10 +596,11 @@ const editableRowDirty = (row: PostImportRow): boolean =>
 
 
 const PostImportReviewPage: FC = ({ user }) => {
+  const locale = useLocale ()
   const editable = canEditContent (user)
   const dialogue = useDialogue ()
   const location = useLocation ()
-  const navigate = useNavigate ()
+  const navigate = useLocaleNavigate ()
   const behaviourSettings = useClientBehaviourSettings ()
   const animationMode = behaviourSettings.animation ?? 'normal'
   const existingRowsTransition =
@@ -696,7 +698,7 @@ const PostImportReviewPage: FC = ({ user }) => {
 
 	  try
 	  {
-	    const preview = await apiGet ('/posts/metadata', {
+	    const preview = await apiGet ('/posts/metadata', { locale,
 		params: { url: currentRow.url },
 		signal: controller.signal })
 	    if (!(active) || metadataSequenceRef.current !== sequence)
@@ -770,7 +772,7 @@ const PostImportReviewPage: FC = ({ user }) => {
       active = false
       controllers.forEach (controller => controller.abort ())
     }
-  }, [commitRows, currentRowBySource, location.search, updateRows])
+  }, [commitRows, currentRowBySource, location.search, updateRows, locale])
 
   useEffect (() => {
     if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed')
@@ -871,12 +873,12 @@ const PostImportReviewPage: FC = ({ user }) => {
 	  urlChanged
 	  ? mergePreviewRow (
 	      nextRow,
-	      await apiGet ('/posts/metadata', {
+	      await apiGet ('/posts/metadata', { locale,
 		  params: { url: nextRow.url } }))
 	  : nextRow
       const dryRun = await apiPost (
 	  '/posts?dry=1',
-	  buildDryRunFormData (candidateRow))
+	  buildDryRunFormData (candidateRow), { locale })
       const mergedRow = mergeDryRunRow (candidateRow, dryRun)
       const mergedRows = updateRows (currentRows =>
 	  replaceImportRow (currentRows, mergedRow))
@@ -974,7 +976,7 @@ const PostImportReviewPage: FC = ({ user }) => {
 
       const result = await apiPost<{ results: BulkApiRow[] }>(
 	  '/posts/bulk',
-	  buildBulkFormData ([target]))
+	  buildBulkFormData ([target]), { locale })
       if (result.results.length !== 1)
 	{
 	  updateRows (currentRows => replaceImportRow (currentRows, originalRow))
@@ -1018,7 +1020,7 @@ const PostImportReviewPage: FC = ({ user }) => {
     {
       const result = await apiPost<{ results: BulkApiRow[] }>(
 	  '/posts/bulk',
-	  buildBulkFormData (latestProcessingRows))
+	  buildBulkFormData (latestProcessingRows), { locale })
       if (result.results.length !== latestProcessingRows.length)
 	{
 	  toast ({ title: '登録結果が不完全でした' })
diff --git a/frontend/src/pages/posts/PostImportSourcePage.tsx b/frontend/src/pages/posts/PostImportSourcePage.tsx
index fc65b69..cc0783a 100644
--- a/frontend/src/pages/posts/PostImportSourcePage.tsx
+++ b/frontend/src/pages/posts/PostImportSourcePage.tsx
@@ -1,7 +1,8 @@
 import { useEffect, useRef, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
-import { useNavigate } from 'react-router-dom'
 
+
+import { useLocaleNavigate } from '@/lib/useLocale'
 import FieldError from '@/components/common/FieldError'
 import Form from '@/components/common/Form'
 import FormField from '@/components/common/FormField'
@@ -40,7 +41,7 @@ const SOURCE_ISSUES_ID = 'post-import-source-issues'
 
 const PostImportSourcePage: FC = ({ user }) => {
   const editable = canEditContent (user)
-  const navigate = useNavigate ()
+  const navigate = useLocaleNavigate ()
   const [source, setSource] = useState ('')
   const [loading, setLoading] = useState (false)
   const [sourceIssues, setSourceIssues] = useState> ([])
diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx
index 6f56c9d..3568dec 100644
--- a/frontend/src/pages/posts/PostListPage.tsx
+++ b/frontend/src/pages/posts/PostListPage.tsx
@@ -1,8 +1,9 @@
 import { useQuery } from '@tanstack/react-query'
 import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import { Helmet } from 'react-helmet-async'
-import { useLocation, useNavigate } from 'react-router-dom'
+import { useLocation } from 'react-router-dom'
 
+import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
 import PostList from '@/components/PostList'
 import PrefetchLink from '@/components/PrefetchLink'
 import TagSidebar from '@/components/TagSidebar'
@@ -33,12 +34,13 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
 
 
 const PostListPage: FC = () => {
+  const locale = useLocale ()
   const containerRef = useRef (null)
 
   const [wikiPage, setWikiPage] = useState (null)
 
   const location = useLocation ()
-  const navigate = useNavigate ()
+  const navigate = useLocaleNavigate ()
   const query = new URLSearchParams (location.search)
   const tagsQuery = query.get ('tags') ?? ''
   const anyFlg = query.get ('match') === 'any'
@@ -59,8 +61,8 @@ const PostListPage: FC = () => {
       createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
       order } as const
   const { data, isLoading: loading } = useQuery ({
-      queryKey: postsKeys.index (keys),
-      queryFn:  () => fetchPosts (keys) })
+      queryKey: postsKeys.index (keys, locale),
+      queryFn:  () => fetchPosts (keys, locale) })
   const posts = data?.posts ?? []
   const cursor = ''
   const totalPages = data ? Math.ceil (data.count / limit) : 0
@@ -111,14 +113,14 @@ const PostListPage: FC = () => {
       try
       {
 	const tagName = tags[0]
-	setWikiPage (await fetchWikiPageByTitle (tagName, { }))
+	setWikiPage (await fetchWikiPageByTitle (tagName, { }, locale))
       }
       catch
       {
 	;
       }
     }) ()
-  }, [location.search, tags])
+  }, [location.search, tags, locale])
 
   return (
       
{ const PostSearchPage: FC = () => { + const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const thumbnailTransition = clientAnimationTransition ( @@ -79,7 +81,7 @@ const PostSearchPage: FC = () => { ) const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const query = useMemo (() => new URLSearchParams (location.search), [location.search]) @@ -130,8 +132,8 @@ const PostSearchPage: FC = () => { updatedTo: qUpdatedTo, order } const { data, isLoading: loading } = useQuery ({ - queryKey: postsKeys.index (keys), - queryFn: () => fetchPosts (keys) }) + queryKey: postsKeys.index (keys, locale), + queryFn: () => fetchPosts (keys, locale) }) const results = data?.posts ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 diff --git a/frontend/src/pages/tags/NicoTagListPage.tsx b/frontend/src/pages/tags/NicoTagListPage.tsx index 74f4d77..a7cb63e 100644 --- a/frontend/src/pages/tags/NicoTagListPage.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.tsx @@ -2,8 +2,9 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Check, LoaderCircle, Pencil, X } from 'lucide-react' import { Fragment, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import TagLink from '@/components/TagLink' import SortHeader from '@/components/SortHeader' import FieldError from '@/components/common/FieldError' @@ -40,11 +41,12 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => { const NicoTagListPage: FC = ({ user }) => { + const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const dialogue = useDialogue () const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const queryClient = useQueryClient () const query = useMemo (() => new URLSearchParams (location.search), [location.search]) @@ -66,8 +68,8 @@ const NicoTagListPage: FC = ({ user }) => { const keys = { name: qName, linkedTag: qLinkedTag, linkStatus: qLinkStatus, page, limit, order } const { data, isError, isLoading: loading } = useQuery ({ - queryKey: tagsKeys.nicoIndex (keys), - queryFn: () => fetchNicoTags (keys) }) + queryKey: tagsKeys.nicoIndex (keys, locale), + queryFn: () => fetchNicoTags (keys, locale) }) const nicoTags = data?.tags ?? [] const count = data?.count ?? 0 @@ -128,7 +130,7 @@ const NicoTagListPage: FC = ({ user }) => { try { await apiPut (`/tags/nico/${ id }`, formData, - { headers: { 'Content-Type': 'multipart/form-data' } }) + { locale, headers: { 'Content-Type': 'multipart/form-data' } }) setErrorsByTagId (errors => ({ ...errors, [id]: [] })) setEditingId (null) await queryClient.invalidateQueries ({ queryKey: tagsKeys.nicoRoot }) diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx index d1e3e76..64f87ac 100644 --- a/frontend/src/pages/tags/TagDetailPage.tsx +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' +import { useLocale } from '@/lib/useLocale' import TagLink from '@/components/TagLink' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' @@ -28,13 +29,14 @@ type TagFormField = const TagDetailPage: FC = () => { + const locale = useLocale () const { id } = useParams () const tagId = String (id ?? '') - const tagKey = tagsKeys.show (tagId) + const tagKey = tagsKeys.show (tagId, locale) const { data: tag, isLoading: loading } = useQuery ({ queryKey: tagKey, - queryFn: () => fetchTag (tagId) }) + queryFn: () => fetchTag (tagId, locale) }) const [name, setName] = useState ('') const [category, setCategory] = useState ('general') @@ -60,7 +62,7 @@ const TagDetailPage: FC = () => { try { - const data = await apiPut (`/tags/${ id }`, formData) + const data = await apiPut (`/tags/${ id }`, formData, { locale }) setName (data.name) setCategory (data.category as Category) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index f95d2ae..28061cc 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation } from 'react-router-dom' +import { useLocale } from '@/lib/useLocale' import PrefetchLink from '@/components/PrefetchLink' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' @@ -45,6 +46,7 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null }) const TagHistoryPage: FC = () => { + const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () @@ -54,8 +56,8 @@ const TagHistoryPage: FC = () => { const limit = Number (query.get ('limit') ?? 20) const { data, isLoading: loading } = useQuery ({ - queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), - queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) + queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale), + queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) }) const changes = data?.versions ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 @@ -210,7 +212,7 @@ const TagHistoryPage: FC = () => { change.parentTags .filter (t => t.type !== 'removed') .map (t => t.tag.name) - .join (' ') }) + .join (' ') }, { locale }) qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index 8dde00d..ad34248 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import PrefetchLink from '@/components/PrefetchLink' import SortHeader from '@/components/SortHeader' import TagLink from '@/components/TagLink' @@ -73,11 +74,12 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => { const TagListPage: FC = () => { + const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const query = useMemo (() => new URLSearchParams (location.search), [location.search]) @@ -129,8 +131,8 @@ const TagListPage: FC = () => { updatedTo: qUpdatedTo, deprecated: qDeprecated } const { data, isLoading: loading } = useQuery ({ - queryKey: tagsKeys.index (keys), - queryFn: () => fetchTags (keys) }) + queryKey: tagsKeys.index (keys, locale), + queryFn: () => fetchTags (keys, locale) }) const results = data?.tags ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 1c99361..550cc99 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' +import { useLocale } from '@/lib/useLocale' import ErrorScreen from '@/components/ErrorScreen' import PostEditForm from '@/components/PostEditForm' import PostEmbed from '@/components/PostEmbed' @@ -215,6 +216,7 @@ type Props = { user: User | null } const TheatreDetailPage: FC = ({ user }: Props) => { + const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const layoutTransition = clientAnimationTransition ( @@ -286,16 +288,16 @@ const TheatreDetailPage: FC = ({ user }: Props) => { return setProgrammes (await apiGet ( - `/theatres/${ id }/programmes`, { params: { limit: '100' } })) - }, [id]) + `/theatres/${ id }/programmes`, { locale, params: { limit: '100' } })) + }, [id, locale]) const refreshWeights = useCallback (async () => { if (!(id)) return setWeights (await apiGet ( - `/theatres/${ id }/post_selection_weights`)) - }, [id]) + `/theatres/${ id }/post_selection_weights`, { locale })) + }, [id, locale]) const advancePost = useCallback (async () => { if (!(id)) @@ -304,7 +306,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { setLoading (true) try { - await apiPatch (`/theatres/${ id }/next_post`) + await apiPatch (`/theatres/${ id }/next_post`, { }, { locale }) await refreshProgrammes () await refreshWeights () } @@ -316,7 +318,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { { setLoading (false) } - }, [id, refreshProgrammes, refreshWeights]) + }, [id, refreshProgrammes, refreshWeights, locale]) useEffect (() => { loadingRef.current = loading @@ -365,7 +367,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { void (async () => { try { - const data = await apiGet (`/theatres/${ id }`) + const data = await apiGet (`/theatres/${ id }`, { locale }) if (!(cancelled)) setTheatre (data) } @@ -381,7 +383,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { return () => { cancelled = true } - }, [id, refreshProgrammes, refreshWeights]) + }, [id, refreshProgrammes, refreshWeights, locale]) useEffect (() => { if (!(id)) @@ -400,7 +402,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { { const newComments = await apiGet ( `/theatres/${ id }/comments`, - { params: { no_gt: lastCommentNoRef.current, limit: '20' } }) + { locale, params: { no_gt: lastCommentNoRef.current, limit: '20' } }) if (!(cancelled) && newComments.length > 0) { @@ -433,7 +435,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { } const watchingRequestedAt = performance.now () - const nextInfo = await apiPut (`/theatres/${ id }/watching`) + const nextInfo = await apiPut (`/theatres/${ id }/watching`, { }, { locale }) if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current) applyTheatreInfo (nextInfo) } @@ -454,7 +456,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { cancelled = true clearInterval (interval) } - }, [applyTheatreInfo, currentPostElapsedMs, id]) + }, [applyTheatreInfo, currentPostElapsedMs, id, locale]) useEffect (() => { if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) @@ -487,7 +489,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { void (async () => { try { - const nextPost = await fetchPost (String (theatreInfo.postId)) + const nextPost = await fetchPost (String (theatreInfo.postId), locale) if (!(cancelled)) setPost (nextPost) } @@ -500,7 +502,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { return () => { cancelled = true } - }, [theatreInfo.postId]) + }, [theatreInfo.postId, locale]) useEffect (() => { void refreshProgrammes () @@ -570,9 +572,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const nextInfo = theatreInfo.skipVote.voted ? await apiDelete ( - `/theatres/${ id }/skip_vote`, { params: { post_id: post.id } }) + `/theatres/${ id }/skip_vote`, { locale, params: { post_id: post.id } }) : await apiPut ( - `/theatres/${ id }/skip_vote`, { post_id: post.id }) + `/theatres/${ id }/skip_vote`, { post_id: post.id }, { locale }) applyTheatreInfo (nextInfo) @@ -586,7 +588,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { catch (error) { if (isApiError (error) && error.response?.status === 409) - applyTheatreInfo (await apiPut (`/theatres/${ id }/watching`)) + applyTheatreInfo (await apiPut (`/theatres/${ id }/watching`, { }, { locale })) console.error (error) } @@ -600,7 +602,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const handleDelete = async (commentNo: number) => { try { - await apiDelete (`/theatres/${ id }/comments/${ commentNo }`) + await apiDelete (`/theatres/${ id }/comments/${ commentNo }`, { locale }) setComments (prev => { const rtn = [...prev] const idx = rtn.findIndex (x => x.no === commentNo) @@ -625,7 +627,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { { setSending (true) clearValidationErrors () - await apiPost (`/theatres/${ id }/comments`, { content }) + await apiPost (`/theatres/${ id }/comments`, { content }, { locale }) setContent ('') commentsRef.current?.scrollTo ({ top: 0, diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 1ece04a..572a76c 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom' +import { useBeforeUnload, useLocation } from 'react-router-dom' +import { useLocaleNavigate } from '@/lib/useLocale' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' @@ -251,7 +252,7 @@ const ensureCompleteThemeSlots = ( ): UserThemeSlotMap => USER_THEME_SLOT_SELECTIONS.reduce ( (complete, selection) => { - const [_, baseTheme, slotNo] = selection.split (':') + const [, baseTheme, slotNo] = selection.split (':') complete[selection] = themeSlots[selection] ?? getDefaultUserThemeSlot ( @@ -267,7 +268,7 @@ const comparableThemeTokensForSelection = ( themeSlots: UserThemeSlotMap, selection: UserThemeSlotSelection, ): ThemeTokens => { - const [_, baseTheme, slotNo] = selection.split (':') + const [, baseTheme, slotNo] = selection.split (':') const fallback = getDefaultUserThemeSlot ( baseTheme as BuiltinThemeId, @@ -647,7 +648,7 @@ const ThemeSection: FC = ( const SettingPage: FC = ({ user, setUser }) => { const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const dialogue = useDialogue () const { registerUnsavedChangesSource } = useUnsavedChangesGuard () @@ -811,7 +812,7 @@ const SettingPage: FC = ({ user, setUser }) => { const discardThemeChanges = () => { const selection = displayedThemeSelection.selection - const [_, baseTheme, slotNo] = selection.split (':') + const [, baseTheme, slotNo] = selection.split (':') const nextThemeSlots = { ...draftThemeSlots, [selection]: savedThemeSlots[selection] @@ -894,7 +895,7 @@ const SettingPage: FC = ({ user, setUser }) => { return const selection = displayedThemeSelection.selection - const [_, baseTheme, slotNo] = selection.split (':') + const [, baseTheme, slotNo] = selection.split (':') themeSlots = { ...baseState.themeSlots, [selection]: savedThemeSlots[selection] diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index e082e56..5b4421f 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation, useNavigate, useParams } from 'react-router-dom' +import { useLocation, useParams } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import PostList from '@/components/PostList' import PrefetchLink from '@/components/PrefetchLink' import TagLink from '@/components/TagLink' @@ -24,11 +25,12 @@ import type { Tag } from '@/types' const WikiDetailPage: FC = () => { + const locale = useLocale () const params = useParams () const title = params.title ?? '' const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title]) @@ -37,8 +39,8 @@ const WikiDetailPage: FC = () => { const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({ enabled: Boolean (title) && !(/^\d+$/.test (title)), - queryKey: wikiKeys.show (title, { version }), - queryFn: () => fetchWikiPageByTitle (title, { version }) }) + queryKey: wikiKeys.show (title, { version }, locale), + queryFn: () => fetchWikiPageByTitle (title, { version }, locale) }) const missingWiki = isSuccess && !(isFetching) && wikiPage == null const { data: titleIndex, isPending: lookupPending, @@ -63,8 +65,8 @@ const WikiDetailPage: FC = () => { const { data: tag, isPending: tagPending, isFetching: tagFetching, error: tagError } = useQuery ({ enabled: Boolean (effectiveTitle), - queryKey: tagsKeys.show (effectiveTitle), - queryFn: () => fetchTagByName (effectiveTitle) }) + queryKey: tagsKeys.show (effectiveTitle, locale), + queryFn: () => fetchTagByName (effectiveTitle, locale) }) const keys = { tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '', @@ -73,8 +75,8 @@ const WikiDetailPage: FC = () => { order: 'original_created_at:desc' } as const const { data } = useQuery ({ enabled: Boolean (effectiveTitle) && !(version), - queryKey: postsKeys.index (keys), - queryFn: () => fetchPosts (keys) }) + queryKey: postsKeys.index (keys, locale), + queryFn: () => fetchPosts (keys, locale) }) const posts = data?.posts || [] useEffect (() => { @@ -97,7 +99,7 @@ const WikiDetailPage: FC = () => { void (async () => { try { - const data = await fetchWikiPage (title, { }) + const data = await fetchWikiPage (title, { }, locale) if (active) navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true }) } @@ -110,7 +112,7 @@ const WikiDetailPage: FC = () => { return () => { active = false } - }, [title, navigate]) + }, [title, navigate, locale]) const displayError = error ?? (missingWiki ? lookupError ?? tagError : null) if (displayError != null) diff --git a/frontend/src/pages/wiki/WikiDiffPage.tsx b/frontend/src/pages/wiki/WikiDiffPage.tsx index 5206f2e..b30533e 100644 --- a/frontend/src/pages/wiki/WikiDiffPage.tsx +++ b/frontend/src/pages/wiki/WikiDiffPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useParams } from 'react-router-dom' +import { useLocale } from '@/lib/useLocale' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' @@ -14,6 +15,7 @@ import type { WikiPageDiff } from '@/types' const WikiDiffPage: FC = () => { + const locale = useLocale () const { id } = useParams () const location = useLocation () @@ -29,9 +31,9 @@ const WikiDiffPage: FC = () => { useEffect (() => { void (async () => { - setDiff (await apiGet (`/wiki/${ id }/diff`, { params: { from, to } })) + setDiff (await apiGet (`/wiki/${ id }/diff`, { locale, params: { from, to } })) }) () - }, [from, id, to]) + }, [from, id, to, locale]) return ( diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 5b95db9..ef41c70 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -3,8 +3,9 @@ import MarkdownIt from 'markdown-it' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import MdEditor from 'react-markdown-editor-lite' -import { useParams, useNavigate } from 'react-router-dom' +import { useParams } from 'react-router-dom' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import MainArea from '@/components/layout/MainArea' @@ -36,11 +37,12 @@ type WikiFormField = 'title' | 'body' const WikiEditPage: FC = ({ user }) => { + const locale = useLocale () const editable = canEditContent (user) const { id } = useParams () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const qc = useQueryClient () @@ -60,8 +62,8 @@ const WikiEditPage: FC = ({ user }) => { try { await apiPut (`/wiki/${ id }`, formData, - { headers: { 'Content-Type': 'multipart/form-data' } }) - qc.setQueryData (wikiKeys.show (title, { }), + { locale, headers: { 'Content-Type': 'multipart/form-data' } }) + qc.setQueryData (wikiKeys.show (title, { }, locale), (prev: WikiPage) => ({ ...prev, title, body })) qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ title: '投稿成功!' }) @@ -80,12 +82,12 @@ const WikiEditPage: FC = ({ user }) => { void (async () => { setLoading (true) - const data = await apiGet (`/wiki/${ id }`) + const data = await apiGet (`/wiki/${ id }`, { locale }) setTitle (data.title) setBody (data.body) setLoading (false) }) () - }, [editable, id]) + }, [editable, id, locale]) if (!(editable)) return diff --git a/frontend/src/pages/wiki/WikiHistoryPage.tsx b/frontend/src/pages/wiki/WikiHistoryPage.tsx index 0affe21..2d072e7 100644 --- a/frontend/src/pages/wiki/WikiHistoryPage.tsx +++ b/frontend/src/pages/wiki/WikiHistoryPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation } from 'react-router-dom' +import { useLocale } from '@/lib/useLocale' import PrefetchLink from '@/components/PrefetchLink' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' @@ -15,6 +16,7 @@ import type { WikiPageChange } from '@/types' const WikiHistoryPage: FC = () => { + const locale = useLocale () const [changes, setChanges] = useState ([]) const location = useLocation () @@ -23,9 +25,9 @@ const WikiHistoryPage: FC = () => { useEffect (() => { void (async () => { - setChanges (await apiGet ('/wiki/changes', { params: id ? { id } : { } })) + setChanges (await apiGet ('/wiki/changes', { locale, params: id ? { id } : { } })) }) () - }, [id, location.search]) + }, [id, location.search, locale]) return ( diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index 60ee6a9..7b107fa 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -3,9 +3,10 @@ import MarkdownIt from 'markdown-it' import { useState } from 'react' import { Helmet } from 'react-helmet-async' import MdEditor from 'react-markdown-editor-lite' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation } from 'react-router-dom' import 'react-markdown-editor-lite/lib/index.css' +import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' import MainArea from '@/components/layout/MainArea' @@ -35,10 +36,11 @@ type WikiFormField = 'title' | 'body' const WikiNewPage: FC = ({ user }) => { + const locale = useLocale () const editable = canEditContent (user) const location = useLocation () - const navigate = useNavigate () + const navigate = useLocaleNavigate () const qc = useQueryClient () const query = new URLSearchParams (location.search) @@ -59,9 +61,9 @@ const WikiNewPage: FC = ({ user }) => { try { const data = await apiPost ('/wiki', formData, - { headers: { 'Content-Type': 'multipart/form-data' } }) + { locale, headers: { 'Content-Type': 'multipart/form-data' } }) await qc.cancelQueries ({ queryKey: wikiKeys.root }) - qc.setQueryData (wikiKeys.show (data.title, { }), data) + qc.setQueryData (wikiKeys.show (data.title, { }, locale), data) await Promise.all ([ qc.invalidateQueries ({ queryKey: wikiKeys.root }), qc.invalidateQueries ({ queryKey: tagsKeys.root })]) diff --git a/frontend/src/pages/wiki/WikiSearchPage.tsx b/frontend/src/pages/wiki/WikiSearchPage.tsx index 3bb9465..97dc047 100644 --- a/frontend/src/pages/wiki/WikiSearchPage.tsx +++ b/frontend/src/pages/wiki/WikiSearchPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' +import { useLocale } from '@/lib/useLocale' import PrefetchLink from '@/components/PrefetchLink' import FormField from '@/components/common/FormField' import PageTitle from '@/components/common/PageTitle' @@ -15,12 +16,13 @@ import type { WikiPage } from '@/types' const WikiSearchPage: FC = () => { + const locale = useLocale () const [title, setTitle] = useState ('') const [text, setText] = useState ('') const [results, setResults] = useState ([]) const search = async () => { - setResults (await apiGet ('/wiki', { params: { title } })) + setResults (await apiGet ('/wiki', { locale, params: { title } })) } const handleSearch = (ev: FormEvent) => { @@ -30,9 +32,9 @@ const WikiSearchPage: FC = () => { useEffect (() => { void (async () => { - setResults (await apiGet ('/wiki', { params: { title: '' } })) + setResults (await apiGet ('/wiki', { locale, params: { title: '' } })) }) () - }, []) + }, [locale]) return ( diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f1d6e9f..8dcd703 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -16,7 +16,7 @@ if (typeof originalTransform === 'function') function (code, id) { return (/[?&](raw|url)(?:&|$)/.test (id) - ? null + ? Promise.resolve (undefined) : originalTransform.call (this, code, id)) } }