diff --git a/AGENTS.md b/AGENTS.md index d5eaec8..0e0c9ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,9 @@ - Counts の取得対象の ID・集合・件数・並び順を UI ロカールに依存させない。 言語・文字体系を抽出条件に使う場合は明示的な項目・条件として扱う。 - API は記録済みの全言語名と別名を保持する。履歴の名前を現在の名前で置換しない。 - 言語名列は既存 Locale の言語・文字体系の組から定義し、未登録値を別言語で補完しない。 + 名称・別名列は Language の言語コードから一言語一列で定義し、Locale ごとに増やさない。 + 同じ言語の全文字体系を含め、script_code 等の記録属性を保持する。 + 未登録値を別言語で補完しない。旧ロカール限定条件の互換処理では意味を無言で変えない。 初期表示列だけは UI ロカールに合わせてよい。明示的な列指定・空選択を優先する。 - 明細の内表は行集合を保持し、表示列・列順・列幅だけを表示設定として操作する。 内表行を抽出・除外する表示機能を追加しない。内表を参照する抽出条件は外表の diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 98de7dc..e476fbd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -24,6 +24,8 @@ UI 言語を名前の WHERE・ORDER BY へ暗黙に使わない。名前検索は記録済みの 全言語又は明示された言語・文字体系を使う。関連名にも HistoryAccess を適用する。 JSON は全言語名を保持し、CSV の列選択を理由に元スナップショットを削らない。 + 名称・別名列は Language から生成し、Locale の script_code で同じ言語の値を落とさない。 + 旧ロカール列の条件互換は入力境界に限定し、旧キーの文字体系限定を維持する。 ## Mandatory naming contract diff --git a/backend/app/controllers/counts_tables_controller.rb b/backend/app/controllers/counts_tables_controller.rb index bc1dea4..9f88753 100644 --- a/backend/app/controllers/counts_tables_controller.rb +++ b/backend/app/controllers/counts_tables_controller.rb @@ -7,6 +7,8 @@ class CountsTablesController < ApplicationController filter_fields: definition.filter_metadata, axes: { latest: definition.axes(false), history: definition.axes(true) }, name_columns: definition.name_columns, + column_aliases: definition.column_aliases, + condition_aliases: definition.condition_aliases, grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource], locale: locale.code) } rescue ArgumentError diff --git a/backend/app/representations/counts_version_repr.rb b/backend/app/representations/counts_version_repr.rb index 92f2d25..8073b7b 100644 --- a/backend/app/representations/counts_version_repr.rb +++ b/backend/app/representations/counts_version_repr.rb @@ -87,15 +87,12 @@ class CountsVersionRepr if field[:multiple_values] entries = values.select { _1[:language_code] == locale[:language_code] } aliases = entries.flat_map do |entry| - entry[:aliases].select do |value| - value['script_code'].to_s.downcase == locale[:script_code].to_s.downcase - end.map { _1.merge('language_code' => entry[:language_code]) } + entry[:aliases].map { _1.merge('language_code' => entry[:language_code]) } end next [field.fetch(:key).to_sym, aliases] end entry = values.find do |value| - value[:language_code] == locale[:language_code] && - value[:script_code].to_s.downcase == locale[:script_code].to_s.downcase + value[:language_code] == locale[:language_code] end [field.fetch(:key).to_sym, entry && entry[:name]] end diff --git a/backend/app/services/counts_condition.rb b/backend/app/services/counts_condition.rb index 41b4ccb..63cbe87 100644 --- a/backend/app/services/counts_condition.rb +++ b/backend/app/services/counts_condition.rb @@ -35,6 +35,7 @@ class CountsCondition operators: CountsResourceDefinition::OPERATORS.fetch(type) } entry[:label_values] = field[:label_values] if field[:label_values] entry[:name_locale] = field[:name_locale] if field[:name_locale] + entry[:legacy] = true if field[:legacy] result.reject! { _1[:key] == key } result << entry visit.call(field[:children] || [], key, path_labels) diff --git a/backend/app/services/counts_defaults.rb b/backend/app/services/counts_defaults.rb index 4c00efd..db864e1 100644 --- a/backend/app/services/counts_defaults.rb +++ b/backend/app/services/counts_defaults.rb @@ -13,8 +13,9 @@ class CountsDefaults 'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } } details = detail_params(resource) if details - name = CountsResourceDefinition.name_key(locale) - aliases = CountsResourceDefinition.aliases_key(locale) + language = Locale.find_by(code: locale)&.language_code || locale + name = CountsResourceDefinition.name_key(language) + aliases = CountsResourceDefinition.aliases_key(language) details = details.transform_values do |value| value.split(' ').flat_map do |token| if resource == 'tags' && ['name', 'parents.name'].include?(token) diff --git a/backend/app/services/counts_resource_definition.rb b/backend/app/services/counts_resource_definition.rb index 5136653..448c3b2 100644 --- a/backend/app/services/counts_resource_definition.rb +++ b/backend/app/services/counts_resource_definition.rb @@ -2,6 +2,10 @@ class CountsResourceDefinition # Compatibility belongs at the request boundary, not in field definitions. def self.normalise_params(params) + aliases = nil + predicates = nil + scope = params['name_scope'] + raise ArgumentError if scope && !['locale', 'language'].include?(scope) params.each_with_object({}) do |(key, value), result| result[key] = value next unless value.is_a?(String) @@ -14,6 +18,16 @@ class CountsResourceDefinition value == 'is_latest') result[key] = 'latest' end + if (['columns', 'children', 'order', 'sort', 'child_sort'].include?(key) && + value.match?(/(?:name|aliases)_[a-z]+_[a-z0-9_]+/)) + aliases ||= new('tags').column_aliases.to_h { [_1[:key], _1[:value]] } + result[key] = result[key].gsub(/(?:name|aliases)_[a-z0-9_]+/) { aliases.fetch(_1, _1) } + end + if (scope != 'language' && key.match?(/\Aconditions\.[0-9]+\.field\z/) && + value.match?(/(?:name|aliases)_[a-z0-9_]+/)) + predicates ||= new('tags').condition_aliases.to_h { [_1[:key], _1[:value]] } + result[key] = value.gsub(/(?:name|aliases)_[a-z0-9_]+/) { predicates.fetch(_1, _1) } + end end end @@ -116,12 +130,8 @@ class CountsResourceDefinition def name_columns @name_columns ||= begin - locales = Locale.joins(:language) - .select('locales.code', 'locales.language_code', 'locales.script_code') - .order('languages.created_at ASC', 'languages.code ASC', 'locales.code ASC') - codes = locales.map do |locale| - { code: locale.code, language_code: locale.language_code, - script_code: locale.script_code } + codes = Language.select(:code).order(:created_at, :code).map do |language| + { code: language.code, language_code: language.code } end names = codes.map do |locale| { key: self.class.name_key(locale[:code]), label: 'counts:localeName', @@ -135,7 +145,45 @@ class CountsResourceDefinition end end - def name_column(key) = name_columns.find { _1[:key] == key } + # Old locale-qualified predicates keep their script restriction at the input boundary. + def legacy_name_columns + @legacy_name_columns ||= Locale.select(:code, :language_code, :script_code) + .order(:code).flat_map do |locale| + codes = { code: locale.code, language_code: locale.language_code, + script_code: locale.script_code } + code = locale.code == locale.language_code ? "#{ locale.code }_#{ locale.script_code }" : + locale.code + [{ key: self.class.name_key(code), label: 'counts:localeName', + name_locale: codes, legacy: true }, + { key: self.class.aliases_key(code), label: 'counts:localeAliases', + name_locale: codes, multiple_values: true, legacy: true }] + end + end + + def column_aliases + legacy_name_columns.map do |field| + language = field[:name_locale][:language_code] + value = if field[:multiple_values] + self.class.aliases_key(language) + else self.class.name_key(language) + end + { key: field[:key], value: } + end + end + + def condition_aliases + legacy_name_columns.filter_map do |field| + code = field[:name_locale][:code] + key = field[:multiple_values] ? self.class.aliases_key(code) : self.class.name_key(code) + next if key == field[:key] + + { key:, value: field[:key] } + end + end + + def name_column(key) + name_columns.find { _1[:key] == key } || legacy_name_columns.find { _1[:key] == key } + end def name_expression(key, row = 'v') if key.start_with?('aliases_') @@ -148,21 +196,26 @@ class CountsResourceDefinition locale = name_column(key).fetch(:name_locale) connection = model.connection path = connection.quote("$.#{ locale[:language_code].to_json }") + value = "JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{ row }.names, " + + "CONCAT(#{ path }, '.name')), CAST('null' AS JSON)))" + return value unless locale[:script_code] + script = connection.quote(locale[:script_code]) "CASE WHEN LOWER(JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, " + "CONCAT(#{ path }, '.script_code')))) = LOWER(#{ script }) " + - "THEN JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{ row }.names, " + - "CONCAT(#{ path }, '.name')), CAST('null' AS JSON))) END" + "THEN #{ value } END" end def alias_source(key, row = 'v') locale = name_column(key).fetch(:name_locale) connection = model.connection path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]") - script = connection.quote(locale[:script_code]) source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " + "COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " + "script TEXT PATH '$.script_code')) locale_alias" + return [source, 'locale_alias.name'] unless locale[:script_code] + + script = connection.quote(locale[:script_code]) value = "CASE WHEN LOWER(locale_alias.script) = LOWER(#{ script }) " + 'THEN locale_alias.name END' [source, value] @@ -231,11 +284,11 @@ class CountsResourceDefinition fields = fields.map do |field| next field unless field[:key] == 'parents' - field.merge(children: field[:children] + [ + field.merge(children: field[:children] + legacy_name_columns + [ { key: 'name', label: 'history:name' }, { key: 'names', label: 'counts:columns.names', children: child_fields('names') }]) end - fields += [{ key: 'name', label: 'counts:columns.name' }, + fields += legacy_name_columns + [{ key: 'name', label: 'counts:columns.name' }, { key: 'names', label: 'counts:columns.names', children: child_fields('names') }, { key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }] end @@ -243,7 +296,7 @@ class CountsResourceDefinition fields = fields.map do |field| next field unless field[:key] == 'tags' - field.merge(children: field[:children] + [ + field.merge(children: field[:children] + legacy_name_columns + [ { key: 'name', label: 'history:name' }, { key: 'names', label: 'counts:columns.names', children: child_fields('names') }, { key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }]) diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb index a33421f..c4f6744 100644 --- a/backend/app/services/counts_table_query.rb +++ b/backend/app/services/counts_table_query.rb @@ -6,7 +6,7 @@ class CountsTableQuery KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through', 'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match', 'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', - 'measures', 'column_order', 'condition_values'].freeze + 'measures', 'column_order', 'condition_values', 'name_scope'].freeze attr_reader :population, :axes, :page, :limit, :mode def initialize(params) diff --git a/docs/counts-language-columns.md b/docs/counts-language-columns.md new file mode 100644 index 0000000..3b4f45c --- /dev/null +++ b/docs/counts-language-columns.md @@ -0,0 +1,26 @@ +# Counts の言語別列 + +- 列は `languages.created_at ASC, languages.code ASC` の順に生成する。 + 全名称列の後に全別名列を置く。Locale の数によって列を重複させない。 +- 識別子は `name_ja`、`name_ain`、`aliases_ja`、`aliases_ain` 等とする。 + 言語の名称は Frontend の common 辞書を使う。 +- 名称は記録済みの言語の主名称、別名はその言語の全別名を使う。 + 特定の文字体系で絞らず、記録済みの script_code 等を保持する。 + 履歴の時点評価と HistoryAccess は従来どおり適用する。 +- 初期選択には UI ロカールの言語コードを使う。明示的な列・空選択は上書きしない。 + +## 旧 URL・API + +- 出力列、子列、列順、ソートの旧 `name_ain_kana` 等は、入力境界で + `name_ain` 等へ正規化する。表示・ソートは言語全体の値を対象とするため、 + 従来表示されなかった文字体系の値が表示され、並びが変わる場合がある。 +- 抽出条件の旧ロカール付きキーは別扱いとし、従来の文字体系限定 SQL を維持する。 + 新規条件候補には出さない。既存条件の表示・編輯時だけ文字体系付きの項目名を示す。 + 利用者が新しい言語単位の項目を選択した場合にだけ、その条件を言語全体へ切り替える。 +- 新しい条件の解釈を `name_scope=language` で明示する。 + 未指定又は `locale` の旧条件は旧仕様として扱い、例えば `name_ja` を + `name_ja_jpan` という入力互換専用キーへ変換する。これにより Jpan 限定を保つ。 + 正規化後の URL は `name_scope=language` と互換専用キーを保存し、再解釈を防ぐ。 +- 新しい API 利用者が言語単位の条件を送る場合も `name_scope=language` を指定する。 + 指定のない既存 API の `name_ja` / `aliases_ja` 条件は従来の Jpan 限定として扱う。 + 未知の name_scope は 400 とする。JSON・Base64 をクエリに格納しない。 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index a966536..8431fdc 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -21,7 +21,7 @@ ## Mandatory Counts data contract - Counts のロカール非依存・内表行集合保持はルート AGENTS.md の必須契約に従う。 - 内表行の表示フィルターを作らない。言語名列はサーバーの Locale 定義を使い、 + 内表行の表示フィルターを作らない。名称・別名列はサーバーの Language コードを使い、 未登録列に別言語名を代入しない。列を隠しても取得済みの全言語データは保持する。 ソート・コピー・JSON の投影には安定したキーと値を使い、UI 訳語を識別に使わない。 diff --git a/frontend/src/components/common/SortableColumns.tsx b/frontend/src/components/common/SortableColumns.tsx index 632f218..8c6482e 100644 --- a/frontend/src/components/common/SortableColumns.tsx +++ b/frontend/src/components/common/SortableColumns.tsx @@ -4,6 +4,7 @@ import { SortableContext, arrayMove, horizontalListSortingStrategy, sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { CSS } from '@dnd-kit/utilities' import { useClientMotion } from '@/lib/useClientMotion' @@ -55,11 +56,16 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: { {children (ids)} - {typeof document !== 'undefined' && createPortal ( + duration: motion.duration ('drop'), easing: motion.easing, + keyframes: ({ transform }) => [ + { transform: CSS.Translate.toString (transform.initial) }, + { transform: CSS.Translate.toString (transform.final) }] }}> {active != null &&
{name (active).text}
}
, document.body)} diff --git a/frontend/src/components/common/SortableItem.tsx b/frontend/src/components/common/SortableItem.tsx index ca19515..a72abf7 100644 --- a/frontend/src/components/common/SortableItem.tsx +++ b/frontend/src/components/common/SortableItem.tsx @@ -40,7 +40,7 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderSh } else item.listeners?.onKeyDown?.(event) }}>⠿ - const style = { transform: CSS.Transform.toString (item.transform), transition: item.transition } + const style = { transform: CSS.Translate.toString (item.transform), transition: item.transition } if (renderShell != null) return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging }) return
{ } -const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = [] }: { +const SortableList = ( + { groups, name, handleName, renderItem, renderGroup, onChange, fixed = [] }: { groups: Groups; name: (id: string) => Name + handleName?: (id: string) => Name renderItem: (id: string, group: string) => ReactNode renderGroup: (id: string, content: ReactNode) => ReactNode onChange: (groups: Groups) => void; fixed?: string[] }, @@ -39,6 +42,7 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = const scope = useId () const [active, setActive] = useState (null) const motion = useClientMotion () + const accessibleName = handleName ?? name const sensors = useSensors ( useSensor (MouseSensor, { activationConstraint: { distance: 4 } }), useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }), @@ -84,13 +88,14 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = } return name (String (event.active.id)).text, + onDragStart: event => accessibleName (String (event.active.id)).text, onDragOver: event => event.over == null ? undefined : ( event.over.id.toString ().startsWith ('group:') ? undefined - : name (String (event.over.id)).text), + : accessibleName (String (event.over.id)).text), onDragMove: () => undefined, onDragEnd: () => undefined, onDragCancel: () => undefined }, - screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }} + screenReaderInstructions: { + draggable: active == null ? '' : accessibleName (active).text } }} onDragStart={event => { const id = String (event.active.id) drag.current = { id, groups } @@ -108,17 +113,23 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = {Object.entries (groups).map (([group, ids]) =>
{renderGroup (group, {ids.map (id => moveGroup (id, direction)}> {renderItem (id, group)} )} )}
)} {typeof document !== 'undefined' && createPortal ( - + [ + { transform: CSS.Translate.toString (transform.initial) }, + { transform: CSS.Translate.toString (transform.final) }] }}> {active == null ? null :
{name (active).text}
}
, document.body)} diff --git a/frontend/src/components/counts/CountsAdditionalConditions.tsx b/frontend/src/components/counts/CountsAdditionalConditions.tsx index 26630f3..23a1d11 100644 --- a/frontend/src/components/counts/CountsAdditionalConditions.tsx +++ b/frontend/src/components/counts/CountsAdditionalConditions.tsx @@ -48,7 +48,8 @@ const ConditionEditor = ( const operator = fields.find (item => item.key === field)?.operators[0] if (operator != null) setCondition ({ field, operator }) }}> - {fields.filter (field => field.operators.length > 0).map (field => ( + {fields.filter (item => item.operators.length > 0 + && (!item.legacy || item.key === field)).map (field => ( ))}