diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 0e247b5..9fe5104 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -10,6 +10,7 @@ class ApplicationController < ActionController::API before_action :reject_banned_ip_address! before_action :authenticate_user before_action :reject_banned_user! + before_action :validate_explicit_locale! def current_user = @current_user @@ -99,6 +100,8 @@ class ApplicationController < ActionController::API end def resolve_locale! + return @resolved_locale if defined?(@resolved_locale) + code = params.fetch(:locale, 'ja') unless code.is_a?(String) && Locale.codes.value?(code) raise UnsupportedLocale @@ -107,6 +110,10 @@ class ApplicationController < ActionController::API locale = Locale.find_by(code:) raise UnsupportedLocale unless locale && locale.deprecated_at.nil? - locale + @resolved_locale = locale + end + + def validate_explicit_locale! + resolve_locale! if params.key?(:locale) end end diff --git a/backend/app/controllers/locales_controller.rb b/backend/app/controllers/locales_controller.rb new file mode 100644 index 0000000..bc99666 --- /dev/null +++ b/backend/app/controllers/locales_controller.rb @@ -0,0 +1,10 @@ +class LocalesController < ApplicationController + def index + locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale| + { code: locale.code_before_type_cast, name: locale.name } + end + + response.headers['Cache-Control'] = 'no-store' + render json: { locales: } + end +end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index f342c5f..b428aca 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -14,7 +14,5 @@ class Locale < ApplicationRecord deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?) end - def generate_tag_names! - TagNameCompletion.for_locale!(self) - end + def generate_tag_names! = TagNameCompletion.for_locale!(self) end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index a0716f5..593c2ad 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -110,13 +110,8 @@ class Tag < ApplicationRecord def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? - def snapshot_aliases(language_code) - 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 + def snapshot_aliases language_code + tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name) end def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) diff --git a/backend/app/services/tag_name_completion.rb b/backend/app/services/tag_name_completion.rb index 2e4c218..901017d 100644 --- a/backend/app/services/tag_name_completion.rb +++ b/backend/app/services/tag_name_completion.rb @@ -1,43 +1,47 @@ 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) + class << self + def 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 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 + + private + + def 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 if name == fallback || !(collision) + + persist_name!(locale, tag, fallback) + end + + def 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 - 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 index 239cbed..c5dbc77 100644 --- a/backend/app/services/tag_name_transcription.rb +++ b/backend/app/services/tag_name_transcription.rb @@ -5,19 +5,52 @@ module TagNameTranscription 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 + parts = [] + NipponeseSegments.split(name).each do |script, text| + output = transcribe_segment(script, text) + if (parts.last && output.first && previous_script && script != previous_script && + NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script)) + parts << '_' + end - boundary ? "_#{ output }" : output + parts.concat(output) + previous_script = script + end + + replace_unconvertible(parts) + end + + def self.transcribe_segment script, text + return [text] if script == :literal + return [nil] if script == :unconvertible + return transcribe_reading(text) unless script == :kanzi + + text.each_char.flat_map do |character| + transcribe_reading(JisReadings.read(character)) + rescue Unconvertible + [nil] + end + end + + def self.transcribe_reading reading + Phonology.adapt(reading).map do |sound| + sound && Katakana.render([sound]) + rescue Unconvertible + nil + end + end + + def self.replace_unconvertible parts + grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group| + unconvertible ? [nil] : group + } + first = grouped.index { _1&.match?(/[^_]/) } + last = grouped.rindex { _1&.match?(/[^_]/) } + + grouped.each_with_index.map { |part, index| + part || (first && first < index && index < last ? '_' : '') }.join end + + private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible end diff --git a/backend/app/services/tag_name_transcription/katakana.rb b/backend/app/services/tag_name_transcription/katakana.rb index 5bcb710..5c74be4 100644 --- a/backend/app/services/tag_name_transcription/katakana.rb +++ b/backend/app/services/tag_name_transcription/katakana.rb @@ -19,6 +19,8 @@ module TagNameTranscription '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 + def self.render sounds + sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join + end end end diff --git a/backend/app/services/tag_name_transcription/japanese_segments.rb b/backend/app/services/tag_name_transcription/nipponese_segments.rb similarity index 60% rename from backend/app/services/tag_name_transcription/japanese_segments.rb rename to backend/app/services/tag_name_transcription/nipponese_segments.rb index ce35fd8..1924d38 100644 --- a/backend/app/services/tag_name_transcription/japanese_segments.rb +++ b/backend/app/services/tag_name_transcription/nipponese_segments.rb @@ -1,8 +1,8 @@ module TagNameTranscription - module JapaneseSegments - JAPANESE_SCRIPTS = [:kanji, :hiragana, :katakana].freeze + module NipponeseSegments + NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze - def self.japanese?(script) = JAPANESE_SCRIPTS.include?(script) + def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script) def self.split name segments = [] @@ -18,13 +18,13 @@ module TagNameTranscription 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 previous_script if character == 'ー' && nipponese?(previous_script) + return :kanzi if character.match?(/\A\p{Han}\z/) + return :xiragana 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 + :unconvertible end end end diff --git a/backend/app/services/tag_name_transcription/phonology.rb b/backend/app/services/tag_name_transcription/phonology.rb index 86923a0..34c302f 100644 --- a/backend/app/services/tag_name_transcription/phonology.rb +++ b/backend/app/services/tag_name_transcription/phonology.rb @@ -24,13 +24,36 @@ module TagNameTranscription 'ㇴ' => ['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 + DIGRAPHS = { + 'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so', + 'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co', + 'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co', + 'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.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 } - } + kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー') + characters = kana.scan(/\X/) + sounds = [] + index = 0 + + while index < characters.length + pair = characters[index, 2].join + + if DIGRAPHS.key?(pair) + sounds << DIGRAPHS.fetch(pair) + index += 2 + next + end + + character = characters[index].tr('ャュョ', 'ヤユヨ') + sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character]) + index += 1 + end + + sounds end end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0102d01..8619040 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resources :locales, only: [:index] + resources :tag_groups, only: [:index, :show, :create, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] diff --git a/backend/db/seeds.rb b/backend/db/seeds.rb index 1ea1efe..8b1e0ad 100644 --- a/backend/db/seeds.rb +++ b/backend/db/seeds.rb @@ -11,11 +11,6 @@ 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 = [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 87f319f..948e4ac 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useQuery } from '@tanstack/react-query' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { Fragment, useEffect, useMemo, useState } from 'react' import { createBrowserRouter, @@ -14,7 +15,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 { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales' +import { localesKeys } from '@/lib/queryKeys' import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, @@ -65,6 +67,7 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import type { Dispatch, FC, SetStateAction } from 'react' import type { ClientAnimationMode } from '@/lib/settings' +import type { AvailableLocale } from '@/lib/locales' import type { User } from '@/types' @@ -131,7 +134,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { } -const RoutedApp: FC = () => { +const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) const behaviourSettings = useClientBehaviourSettings () @@ -246,7 +249,7 @@ const RoutedApp: FC = () => { layout={animationMode === 'off' ? false : 'position'} transition={{ layout: appLayoutTransition }} className="relative flex h-dvh w-full flex-col overflow-y-hidden"> - + { const LocaleRoute: FC = () => { const { locale } = useParams () const location = useLocation () - if (isLocaleCode (locale)) - return + const { data, isPending, isError, refetch } = useQuery ({ + queryKey: localesKeys.index, + queryFn: fetchLocales, + staleTime: 0, + refetchOnMount: 'always', + refetchOnWindowFocus: 'always', + refetchOnReconnect: 'always' }) + + useEffect (() => { + void refetch () + }, [location.key, refetch]) const redirect = legacyLocalePath (location) + if (!(isLocaleCode (locale)) && redirect == null) + return + if (isError) + return + if (isPending) + return null + if (isLocaleCode (locale)) + { + return data.locales.some (entry => entry.code === locale) + ? + : + } + return redirect == null ? : } diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 429fbf0..4afbb68 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { AnimatePresence, motion } from 'framer-motion' +import { Globe } from 'lucide-react' import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' @@ -8,9 +9,7 @@ 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, +import { isLocaleCode, localeFromPath, switchLocalePath, unlocalisedPath } from '@/lib/locales' @@ -25,9 +24,10 @@ import { fetchWikiPage } from '@/lib/wiki' import type { FC, MouseEvent } from 'react' +import type { AvailableLocale } from '@/lib/locales' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' -type Props = { user: User | null } +type Props = { user: User | null; locales: AvailableLocale[] } type NavDensity = 'auto' | 'comfortable' | 'compact' @@ -116,8 +116,9 @@ export const menuOutline = ( } -const TopNav: FC = ({ user }) => { +const TopNav: FC = ({ user, locales }) => { const locale = useLocale () + const localeName = locales.find (entry => entry.code === locale)?.name const navigate = useLocaleNavigate () const location = useLocation () const behaviourSettings = useClientBehaviourSettings () @@ -229,7 +230,7 @@ const TopNav: FC = ({ user }) => { if (layout == null || bar == null || primary == null) return - const userLink = bar.querySelector ('.top-nav-user-desktop') + const account = bar.querySelector ('.top-nav-account') let raf = 0 let active = true @@ -252,9 +253,9 @@ const TopNav: FC = ({ user }) => { layout.setAttribute ('data-density', candidate) const style = getComputedStyle (bar) const required = primary.getBoundingClientRect ().width - + (userLink?.getBoundingClientRect ().width ?? 0) + + (account?.getBoundingClientRect ().width ?? 0) + parseFloat (style.paddingLeft) + parseFloat (style.paddingRight) - + (userLink == null ? 0 : parseFloat (style.columnGap)) + + (account == null ? 0 : parseFloat (style.columnGap)) if (required <= bar.clientWidth) return candidate } @@ -283,8 +284,8 @@ const TopNav: FC = ({ user }) => { typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule) observer?.observe (bar) observer?.observe (primary) - if (userLink != null) - observer?.observe (userLink) + if (account != null) + observer?.observe (account) update () addEventListener ('resize', schedule) document.fonts?.addEventListener ('loadingdone', schedule) @@ -297,7 +298,7 @@ const TopNav: FC = ({ user }) => { removeEventListener ('resize', schedule) document.fonts?.removeEventListener ('loadingdone', schedule) } - }, [activeIdx, moreVsbl, desktop, density, user?.name]) + }, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales]) useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) @@ -309,6 +310,32 @@ const TopNav: FC = ({ user }) => { setOpenItemIdx (activeIdx) }, [activeIdx, location]) + const localeSelector = ( + ) + return (
@@ -374,35 +401,25 @@ const TopNav: FC = ({ user }) => {
- +
+ + {localeSelector} +
- +
+ {menuOpen && localeSelector} + +
-
- -
- = ({ user, sp }) => { return ( <> {sp && } - + {user.name || '名もなきニジラー'} ) diff --git a/frontend/src/index.css b/frontend/src/index.css index 7a9835b..e1c3534 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -216,6 +216,7 @@ body .top-nav-desktop-menu, .top-nav-desktop-submenu, +.top-nav-layout .top-nav-account, .top-nav-user-desktop { display: none; @@ -242,8 +243,33 @@ body overflow-wrap: anywhere; } +.top-nav-account .top-nav-user-desktop +{ + min-width: 0; + max-width: 24vw; + overflow: hidden; + text-overflow: ellipsis; +} + +.top-nav-locale-name +{ + display: none; +} + @media (min-width: 768px) { + .top-nav-layout[data-desktop='true'] .top-nav-locale-name, + .top-nav-layout[data-measuring] .top-nav-locale-name + { + display: inline; + } + + .top-nav-layout[data-desktop='true'] .top-nav-account .top-nav-user-desktop, + .top-nav-layout[data-measuring] .top-nav-account .top-nav-user-desktop + { + max-width: none; + } + .top-nav-layout .top-nav-brand-link { font-size: 1.125rem; @@ -255,7 +281,9 @@ body } .top-nav-layout[data-desktop='true'] .top-nav-desktop-menu, - .top-nav-layout[data-measuring] .top-nav-desktop-menu + .top-nav-layout[data-measuring] .top-nav-desktop-menu, + .top-nav-layout[data-desktop='true'] .top-nav-account, + .top-nav-layout[data-measuring] .top-nav-account { display: flex; } @@ -269,6 +297,8 @@ body .top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle, .top-nav-layout[data-measuring] .top-nav-mobile-toggle, + .top-nav-layout[data-desktop='true'] .top-nav-mobile-controls, + .top-nav-layout[data-measuring] .top-nav-mobile-controls, .top-nav-layout[data-desktop='true'] .top-nav-mobile-menu, .top-nav-layout[data-desktop='true'] .top-nav-user-mobile { diff --git a/frontend/src/lib/locales.ts b/frontend/src/lib/locales.ts index b707927..99bd2f4 100644 --- a/frontend/src/lib/locales.ts +++ b/frontend/src/lib/locales.ts @@ -1,9 +1,18 @@ +import { apiGet } from '@/lib/api' + import type { To } from 'react-router-dom' export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const export type LocaleCode = typeof SUPPORTED_LOCALES[number] +export type AvailableLocale = { code: string; name: string } + + +export const fetchLocales = (): Promise<{ locales: AvailableLocale[] }> => + apiGet ('/locales') + + export const LOCALE_LABELS: Record = { ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 8daef0f..8047513 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -7,6 +7,10 @@ import type { MaterialFilter, } from '@/types' +export const localesKeys = { + root: ['locales'] as const, + index: ['locales', 'index'] as const } + export const postsKeys = { root: ['posts'] as const, index: (