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 }: {