このコミットが含まれているのは:
2026-10-28 21:13:08 +09:00
コミット 49721e6915
17個のファイルの変更、170行の追加、48行の削除
+3 -1
ファイルの表示
@@ -21,7 +21,9 @@
- Counts の取得対象の ID・集合・件数・並び順を UI ロカールに依存させない。 - Counts の取得対象の ID・集合・件数・並び順を UI ロカールに依存させない。
言語・文字体系を抽出条件に使う場合は明示的な項目・条件として扱う。 言語・文字体系を抽出条件に使う場合は明示的な項目・条件として扱う。
- API は記録済みの全言語名と別名を保持する。履歴の名前を現在の名前で置換しない。 - API は記録済みの全言語名と別名を保持する。履歴の名前を現在の名前で置換しない。
言語名列は既存 Locale の言語・文字体系の組から定義し、未登録値を別言語で補完しない。 名称・別名列は Language の言語コードから一言語一列で定義し、Locale ごとに増やさない。
同じ言語の全文字体系を含め、script_code 等の記録属性を保持する。
未登録値を別言語で補完しない。旧ロカール限定条件の互換処理では意味を無言で変えない。
初期表示列だけは UI ロカールに合わせてよい。明示的な列指定・空選択を優先する。 初期表示列だけは UI ロカールに合わせてよい。明示的な列指定・空選択を優先する。
- 明細の内表は行集合を保持し、表示列・列順・列幅だけを表示設定として操作する。 - 明細の内表は行集合を保持し、表示列・列順・列幅だけを表示設定として操作する。
内表行を抽出・除外する表示機能を追加しない。内表を参照する抽出条件は外表の 内表行を抽出・除外する表示機能を追加しない。内表を参照する抽出条件は外表の
+2
ファイルの表示
@@ -24,6 +24,8 @@
UI 言語を名前の WHERE・ORDER BY へ暗黙に使わない。名前検索は記録済みの UI 言語を名前の WHERE・ORDER BY へ暗黙に使わない。名前検索は記録済みの
全言語又は明示された言語・文字体系を使う。関連名にも HistoryAccess を適用する。 全言語又は明示された言語・文字体系を使う。関連名にも HistoryAccess を適用する。
JSON は全言語名を保持し、CSV の列選択を理由に元スナップショットを削らない。 JSON は全言語名を保持し、CSV の列選択を理由に元スナップショットを削らない。
名称・別名列は Language から生成し、Locale の script_code で同じ言語の値を落とさない。
旧ロカール列の条件互換は入力境界に限定し、旧キーの文字体系限定を維持する。
## Mandatory naming contract ## Mandatory naming contract
+2
ファイルの表示
@@ -7,6 +7,8 @@ class CountsTablesController < ApplicationController
filter_fields: definition.filter_metadata, filter_fields: definition.filter_metadata,
axes: { latest: definition.axes(false), history: definition.axes(true) }, axes: { latest: definition.axes(false), history: definition.axes(true) },
name_columns: definition.name_columns, name_columns: definition.name_columns,
column_aliases: definition.column_aliases,
condition_aliases: definition.condition_aliases,
grains: CountsAxes::GRAINS, grains: CountsAxes::GRAINS,
defaults: CountsDefaults.metadata(params[:resource], locale: locale.code) } defaults: CountsDefaults.metadata(params[:resource], locale: locale.code) }
rescue ArgumentError rescue ArgumentError
+2 -5
ファイルの表示
@@ -87,15 +87,12 @@ class CountsVersionRepr
if field[:multiple_values] if field[:multiple_values]
entries = values.select { _1[:language_code] == locale[:language_code] } entries = values.select { _1[:language_code] == locale[:language_code] }
aliases = entries.flat_map do |entry| aliases = entries.flat_map do |entry|
entry[:aliases].select do |value| entry[:aliases].map { _1.merge('language_code' => entry[:language_code]) }
value['script_code'].to_s.downcase == locale[:script_code].to_s.downcase
end.map { _1.merge('language_code' => entry[:language_code]) }
end end
next [field.fetch(:key).to_sym, aliases] next [field.fetch(:key).to_sym, aliases]
end end
entry = values.find do |value| entry = values.find do |value|
value[:language_code] == locale[:language_code] && value[:language_code] == locale[:language_code]
value[:script_code].to_s.downcase == locale[:script_code].to_s.downcase
end end
[field.fetch(:key).to_sym, entry && entry[:name]] [field.fetch(:key).to_sym, entry && entry[:name]]
end end
+1
ファイルの表示
@@ -35,6 +35,7 @@ class CountsCondition
operators: CountsResourceDefinition::OPERATORS.fetch(type) } operators: CountsResourceDefinition::OPERATORS.fetch(type) }
entry[:label_values] = field[:label_values] if field[:label_values] entry[:label_values] = field[:label_values] if field[:label_values]
entry[:name_locale] = field[:name_locale] if field[:name_locale] entry[:name_locale] = field[:name_locale] if field[:name_locale]
entry[:legacy] = true if field[:legacy]
result.reject! { _1[:key] == key } result.reject! { _1[:key] == key }
result << entry result << entry
visit.call(field[:children] || [], key, path_labels) visit.call(field[:children] || [], key, path_labels)
+3 -2
ファイルの表示
@@ -13,8 +13,9 @@ class CountsDefaults
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } } 'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
details = detail_params(resource) details = detail_params(resource)
if details if details
name = CountsResourceDefinition.name_key(locale) language = Locale.find_by(code: locale)&.language_code || locale
aliases = CountsResourceDefinition.aliases_key(locale) name = CountsResourceDefinition.name_key(language)
aliases = CountsResourceDefinition.aliases_key(language)
details = details.transform_values do |value| details = details.transform_values do |value|
value.split(' ').flat_map do |token| value.split(' ').flat_map do |token|
if resource == 'tags' && ['name', 'parents.name'].include?(token) if resource == 'tags' && ['name', 'parents.name'].include?(token)
+66 -13
ファイルの表示
@@ -2,6 +2,10 @@
class CountsResourceDefinition class CountsResourceDefinition
# Compatibility belongs at the request boundary, not in field definitions. # Compatibility belongs at the request boundary, not in field definitions.
def self.normalise_params(params) 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| params.each_with_object({}) do |(key, value), result|
result[key] = value result[key] = value
next unless value.is_a?(String) next unless value.is_a?(String)
@@ -14,6 +18,16 @@ class CountsResourceDefinition
value == 'is_latest') value == 'is_latest')
result[key] = 'latest' result[key] = 'latest'
end 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
end end
@@ -116,12 +130,8 @@ class CountsResourceDefinition
def name_columns def name_columns
@name_columns ||= begin @name_columns ||= begin
locales = Locale.joins(:language) codes = Language.select(:code).order(:created_at, :code).map do |language|
.select('locales.code', 'locales.language_code', 'locales.script_code') { code: language.code, language_code: language.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 }
end end
names = codes.map do |locale| names = codes.map do |locale|
{ key: self.class.name_key(locale[:code]), label: 'counts:localeName', { key: self.class.name_key(locale[:code]), label: 'counts:localeName',
@@ -135,7 +145,45 @@ class CountsResourceDefinition
end end
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') def name_expression(key, row = 'v')
if key.start_with?('aliases_') if key.start_with?('aliases_')
@@ -148,21 +196,26 @@ class CountsResourceDefinition
locale = name_column(key).fetch(:name_locale) locale = name_column(key).fetch(:name_locale)
connection = model.connection connection = model.connection
path = connection.quote("$.#{ locale[:language_code].to_json }") 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]) script = connection.quote(locale[:script_code])
"CASE WHEN LOWER(JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, " + "CASE WHEN LOWER(JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, " +
"CONCAT(#{ path }, '.script_code')))) = LOWER(#{ script }) " + "CONCAT(#{ path }, '.script_code')))) = LOWER(#{ script }) " +
"THEN JSON_UNQUOTE(NULLIF(JSON_EXTRACT(#{ row }.names, " + "THEN #{ value } END"
"CONCAT(#{ path }, '.name')), CAST('null' AS JSON))) END"
end end
def alias_source(key, row = 'v') def alias_source(key, row = 'v')
locale = name_column(key).fetch(:name_locale) locale = name_column(key).fetch(:name_locale)
connection = model.connection connection = model.connection
path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]") path = connection.quote("$.#{ locale[:language_code].to_json }.aliases[*]")
script = connection.quote(locale[:script_code])
source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " + source = "JSON_TABLE(COALESCE(#{ row }.names, JSON_OBJECT()), #{ path } " +
"COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " + "COLUMNS (ordinal FOR ORDINALITY, name TEXT PATH '$.name', " +
"script TEXT PATH '$.script_code')) locale_alias" "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 }) " + value = "CASE WHEN LOWER(locale_alias.script) = LOWER(#{ script }) " +
'THEN locale_alias.name END' 'THEN locale_alias.name END'
[source, value] [source, value]
@@ -231,11 +284,11 @@ class CountsResourceDefinition
fields = fields.map do |field| fields = fields.map do |field|
next field unless field[:key] == 'parents' 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: 'name', label: 'history:name' },
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') }]) { key: 'names', label: 'counts:columns.names', children: child_fields('names') }])
end 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: 'names', label: 'counts:columns.names', children: child_fields('names') },
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }] { key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }]
end end
@@ -243,7 +296,7 @@ class CountsResourceDefinition
fields = fields.map do |field| fields = fields.map do |field|
next field unless field[:key] == 'tags' 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: 'name', label: 'history:name' },
{ key: 'names', label: 'counts:columns.names', children: child_fields('names') }, { key: 'names', label: 'counts:columns.names', children: child_fields('names') },
{ key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }]) { key: 'aliases', label: 'tags:aliases', children: child_fields('aliases') }])
+1 -1
ファイルの表示
@@ -6,7 +6,7 @@ class CountsTableQuery
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through', KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match', 'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', '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 attr_reader :population, :axes, :page, :limit, :mode
def initialize(params) def initialize(params)
+26
ファイルの表示
@@ -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 をクエリに格納しない。
+1 -1
ファイルの表示
@@ -21,7 +21,7 @@
## Mandatory Counts data contract ## Mandatory Counts data contract
- Counts のロカール非依存・内表行集合保持はルート AGENTS.md の必須契約に従う。 - Counts のロカール非依存・内表行集合保持はルート AGENTS.md の必須契約に従う。
内表行の表示フィルターを作らない。言語名列はサーバーの Locale 定義を使い、 内表行の表示フィルターを作らない。名称・別名列はサーバーの Language コードを使い、
未登録列に別言語名を代入しない。列を隠しても取得済みの全言語データは保持する。 未登録列に別言語名を代入しない。列を隠しても取得済みの全言語データは保持する。
ソート・コピー・JSON の投影には安定したキーと値を使い、UI 訳語を識別に使わない。 ソート・コピー・JSON の投影には安定したキーと値を使い、UI 訳語を識別に使わない。
+9 -3
ファイルの表示
@@ -4,6 +4,7 @@ import { SortableContext, arrayMove, horizontalListSortingStrategy,
sortableKeyboardCoordinates } from '@dnd-kit/sortable' sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { CSS } from '@dnd-kit/utilities'
import { useClientMotion } from '@/lib/useClientMotion' import { useClientMotion } from '@/lib/useClientMotion'
@@ -55,11 +56,16 @@ const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
<SortableContext items={ids} strategy={horizontalListSortingStrategy}> <SortableContext items={ids} strategy={horizontalListSortingStrategy}>
{children (ids)} {children (ids)}
</SortableContext> </SortableContext>
{typeof document !== 'undefined' && createPortal (<DragOverlay {typeof document !== 'undefined' && createPortal (<DragOverlay adjustScale={false}
style={{ width: 'max-content', height: 'auto', maxWidth: 'calc(100vw - 2rem)' }}
dropAnimation={motion.reduced ? null : { dropAnimation={motion.reduced ? null : {
duration: motion.duration ('drop'), easing: motion.easing }}> duration: motion.duration ('drop'), easing: motion.easing,
keyframes: ({ transform }) => [
{ transform: CSS.Translate.toString (transform.initial) },
{ transform: CSS.Translate.toString (transform.final) }] }}>
{active != null && <div lang={name (active).language} {active != null && <div lang={name (active).language}
className="rounded border border-blue-500 bg-white p-2 text-slate-900 shadow-lg className="w-max max-w-[min(20rem,calc(100vw_-_2rem))] truncate rounded
border border-blue-500 bg-white px-2 py-1 text-sm text-slate-900 shadow-lg
dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">{name (active).text}</div>} dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">{name (active).text}</div>}
</DragOverlay>, document.body)} </DragOverlay>, document.body)}
</DndContext> </DndContext>
+1 -1
ファイルの表示
@@ -40,7 +40,7 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderSh
} }
else item.listeners?.onKeyDown?.(event) else item.listeners?.onKeyDown?.(event)
}}><span aria-hidden>⠿</span></button> }}><span aria-hidden>⠿</span></button>
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) if (renderShell != null)
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging }) return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
return <div ref={item.setNodeRef} style={style} return <div ref={item.setNodeRef} style={style}
+19 -8
ファイルの表示
@@ -4,6 +4,7 @@ import { SortableContext, arrayMove, rectSortingStrategy,
sortableKeyboardCoordinates } from '@dnd-kit/sortable' sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useId, useRef, useState } from 'react' import { useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { CSS } from '@dnd-kit/utilities'
import SortableItem from '@/components/common/SortableItem' import SortableItem from '@/components/common/SortableItem'
import { useClientMotion } from '@/lib/useClientMotion' import { useClientMotion } from '@/lib/useClientMotion'
@@ -29,8 +30,10 @@ const Group = ({ id, children }: { id: string; children: ReactNode }) => {
} }
const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = [] }: { const SortableList = (
{ groups, name, handleName, renderItem, renderGroup, onChange, fixed = [] }: {
groups: Groups; name: (id: string) => Name groups: Groups; name: (id: string) => Name
handleName?: (id: string) => Name
renderItem: (id: string, group: string) => ReactNode renderItem: (id: string, group: string) => ReactNode
renderGroup: (id: string, content: ReactNode) => ReactNode renderGroup: (id: string, content: ReactNode) => ReactNode
onChange: (groups: Groups) => void; fixed?: string[] }, onChange: (groups: Groups) => void; fixed?: string[] },
@@ -39,6 +42,7 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
const scope = useId () const scope = useId ()
const [active, setActive] = useState<string | null> (null) const [active, setActive] = useState<string | null> (null)
const motion = useClientMotion () const motion = useClientMotion ()
const accessibleName = handleName ?? name
const sensors = useSensors ( const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }), useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }), useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
@@ -84,13 +88,14 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
} }
return <DndContext sensors={sensors} collisionDetection={closestCenter} return <DndContext sensors={sensors} collisionDetection={closestCenter}
accessibility={{ announcements: { accessibility={{ announcements: {
onDragStart: event => name (String (event.active.id)).text, onDragStart: event => accessibleName (String (event.active.id)).text,
onDragOver: event => event.over == null ? undefined : ( onDragOver: event => event.over == null ? undefined : (
event.over.id.toString ().startsWith ('group:') ? undefined event.over.id.toString ().startsWith ('group:') ? undefined
: name (String (event.over.id)).text), : accessibleName (String (event.over.id)).text),
onDragMove: () => undefined, onDragEnd: () => undefined, onDragMove: () => undefined, onDragEnd: () => undefined,
onDragCancel: () => undefined }, onDragCancel: () => undefined },
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }} screenReaderInstructions: {
draggable: active == null ? '' : accessibleName (active).text } }}
onDragStart={event => { onDragStart={event => {
const id = String (event.active.id) const id = String (event.active.id)
drag.current = { id, groups } drag.current = { id, groups }
@@ -108,17 +113,23 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
{Object.entries (groups).map (([group, ids]) => <div key={group}> {Object.entries (groups).map (([group, ids]) => <div key={group}>
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}> {renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id} <Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
name={name (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`} name={accessibleName (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`}
moveGroup={direction => moveGroup (id, direction)}> moveGroup={direction => moveGroup (id, direction)}>
{renderItem (id, group)} {renderItem (id, group)}
</SortableItem>)}</Group> </SortableItem>)}</Group>
</SortableContext>)} </SortableContext>)}
</div>)} </div>)}
{typeof document !== 'undefined' && createPortal ( {typeof document !== 'undefined' && createPortal (
<DragOverlay dropAnimation={motion.reduced ? null : { <DragOverlay adjustScale={false}
duration: motion.duration ('drop'), easing: motion.easing }}> style={{ width: 'max-content', height: 'auto', maxWidth: 'calc(100vw - 2rem)' }}
dropAnimation={motion.reduced ? null : {
duration: motion.duration ('drop'), easing: motion.easing,
keyframes: ({ transform }) => [
{ transform: CSS.Translate.toString (transform.initial) },
{ transform: CSS.Translate.toString (transform.final) }] }}>
{active == null ? null : <div lang={name (active).language} {active == null ? null : <div lang={name (active).language}
className="rounded border border-blue-500 bg-white px-3 py-2 text-sm className="w-max max-w-[min(20rem,calc(100vw_-_2rem))] truncate rounded
border border-blue-500 bg-white px-2 py-1 text-sm
text-slate-900 shadow-lg dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100"> text-slate-900 shadow-lg dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">
{name (active).text}</div>} {name (active).text}</div>}
</DragOverlay>, document.body)} </DragOverlay>, document.body)}
+3 -2
ファイルの表示
@@ -48,7 +48,8 @@ const ConditionEditor = (
const operator = fields.find (item => item.key === field)?.operators[0] const operator = fields.find (item => item.key === field)?.operators[0]
if (operator != null) setCondition ({ field, operator }) 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 => (
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))} <option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
</select> </select>
<select value={operator} aria-label={message ('operator').text} <select value={operator} aria-label={message ('operator').text}
@@ -98,7 +99,7 @@ const CountsAdditionalConditions = ({ params, update }: {
const dialogue = useDialogue () const dialogue = useDialogue ()
const conditions = readCountState<CountsCondition[]> (params, 'conditions', []) const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
const metadata = definition.filterFields ?? definition.fields const metadata = definition.filterFields ?? definition.fields
const fields = metadata.filter (field => field.operators.length > 0) const fields = metadata.filter (field => field.operators.length > 0 && !field.legacy)
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
?? [countsFieldLabel (key)]).map (label => message ( ?? [countsFieldLabel (key)]).map (label => message (
label, metadata.find (item => item.key === key)?.labelValues).text).join (' › ') label, metadata.find (item => item.key === key)?.labelValues).text).join (' › ')
+3 -1
ファイルの表示
@@ -69,7 +69,9 @@ const FieldGroup = ({ fields, path, params, enabled, selected, select, update }:
const prefix = path ? `${path}.` : '' const prefix = path ? `${path}.` : ''
return <SortableList groups={{ items: ordered.map (field => field.key) }} return <SortableList groups={{ items: ordered.map (field => field.key) }}
fixed={path === '' ? ['thumbnail'] : []} fixed={path === '' ? ['thumbnail'] : []}
name={key => message ('reorder', { name={key => message (fields.find (field => field.key === key)!.label,
fields.find (field => field.key === key)!.labelValues)}
handleName={key => message ('reorder', {
column: message (fields.find (field => field.key === key)!.label, column: message (fields.find (field => field.key === key)!.label,
fields.find (field => field.key === key)!.labelValues).text })} fields.find (field => field.key === key)!.labelValues).text })}
renderGroup={(_, content) => content} renderGroup={(_, content) => content}
+23 -5
ファイルの表示
@@ -1,6 +1,6 @@
import { countsFieldLabel } from '@/lib/countsDefinitions' import { countsFieldLabel } from '@/lib/countsDefinitions'
import { apiDownload, apiGet } from '@/lib/api' import { apiDownload, apiGet } from '@/lib/api'
import { currentViewingLocale } from '@/lib/locales' import { currentViewingLocale, localeLanguage } from '@/lib/locales'
import { readCountState, encodeCountState } from '@/lib/queryState' import { readCountState, encodeCountState } from '@/lib/queryState'
@@ -99,7 +99,7 @@ export type CountSort = { key: string; direction: 'asc' | 'desc' }
export type CountField = { key: string; label: string; children?: CountField[] export type CountField = { key: string; label: string; children?: CountField[]
labelValues?: Record<string, string> labelValues?: Record<string, string>
multipleValues?: boolean multipleValues?: boolean
nameLocale?: { code: string; languageCode: string; scriptCode: string } } nameLocale?: { code: string; languageCode: string; scriptCode?: string } }
export type CountRecord = Record<string, unknown> export type CountRecord = Record<string, unknown>
export const countColumns: Record<string, string> = { export const countColumns: Record<string, string> = {
@@ -233,7 +233,8 @@ export const selectedCountColumns = (
const defaults: Record<CountsResource, string[]> = { const defaults: Record<CountsResource, string[]> = {
posts: ['title', 'url', 'tags', 'parents', 'video_ms'], posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
tags: [...(definition?.nameColumns ?? []).filter (field => tags: [...(definition?.nameColumns ?? []).filter (field =>
field.nameLocale?.code === currentViewingLocale ()).map (field => field.key), field.nameLocale?.languageCode === localeLanguage (currentViewingLocale ()))
.map (field => field.key),
'category', 'parents'], 'category', 'parents'],
nico_tags: ['name', 'linked_tags'], nico_tags: ['name', 'linked_tags'],
tag_descriptions: ['language_code', 'body', 'source_language_code'], tag_descriptions: ['language_code', 'body', 'source_language_code'],
@@ -356,14 +357,31 @@ export const normaliseCountsParams = (
definition?: CountsDefinition, definition?: CountsDefinition,
): URLSearchParams => { ): URLSearchParams => {
const params = new URLSearchParams (source) const params = new URLSearchParams (source)
const nameScope = params.get ('name_scope')
if (definition?.conditionAliases != null && (nameScope == null || nameScope === 'locale'))
{
const predicates = new Map ((definition.conditionAliases ?? [])
.map (entry => [entry.key, entry.value]))
for (const [key, value] of params)
if (/^conditions\.\d+\.field$/.test (key)) params.set (key, value.replace (
/(?:name|aliases)_[a-z0-9_]+/g, field => predicates.get (field) ?? field))
params.set ('name_scope', 'language')
}
const aliasesByKey = new Map ((definition?.columnAliases ?? [])
.map (entry => [entry.key, entry.value]))
for (const key of ['columns', 'children', 'order', 'sort', 'child_sort'])
if (params.has (key)) params.set (key, params.get (key)!.replace (
/(?:name|aliases)_[a-z0-9_]+/g, field => aliasesByKey.get (field) ?? field))
if (definition?.nameColumns != null) if (definition?.nameColumns != null)
{ {
const names = definition.nameColumns.filter (field => !field.multipleValues) const names = definition.nameColumns.filter (field => !field.multipleValues)
.map (field => field.key) .map (field => field.key)
const current = definition.nameColumns.find (field => const current = definition.nameColumns.find (field =>
!field.multipleValues && field.nameLocale?.code === currentViewingLocale ())?.key !field.multipleValues
&& field.nameLocale?.languageCode === localeLanguage (currentViewingLocale ()))?.key
const aliases = definition.nameColumns.find (field => const aliases = definition.nameColumns.find (field =>
field.multipleValues && field.nameLocale?.code === currentViewingLocale ())?.key field.multipleValues
&& field.nameLocale?.languageCode === localeLanguage (currentViewingLocale ()))?.key
const rootKey = (key: string) => (key === 'name' && current != null) ? current const rootKey = (key: string) => (key === 'name' && current != null) ? current
: ((key === 'aliases' && aliases != null) ? aliases : key) : ((key === 'aliases' && aliases != null) ? aliases : key)
const childKeys = (key: string): string[] => { const childKeys = (key: string): string[] => {
+5 -5
ファイルの表示
@@ -27,11 +27,14 @@ export type CountsFieldMetadata = {
setType?: 'tag_reference'; inputTypes?: string[] setType?: 'tag_reference'; inputTypes?: string[]
labelValues?: Record<string, string> labelValues?: Record<string, string>
nameLocale?: CountField['nameLocale'] nameLocale?: CountField['nameLocale']
legacy?: boolean
choices?: { value: string; label: string }[] } choices?: { value: string; label: string }[] }
export type CountsDefinition = { export type CountsDefinition = {
fields: CountsFieldMetadata[] fields: CountsFieldMetadata[]
outputFields?: CountField[] outputFields?: CountField[]
nameColumns?: CountField[] nameColumns?: CountField[]
columnAliases?: { key: string; value: string }[]
conditionAliases?: { key: string; value: string }[]
filterFields?: CountsFieldMetadata[] filterFields?: CountsFieldMetadata[]
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[] defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
axes: { latest: string[]; history: string[] } axes: { latest: string[]; history: string[] }
@@ -53,12 +56,9 @@ const localiseCountsDefinition = (
const heading = (name: CountField['nameLocale'], aliasesFlg: boolean) => { const heading = (name: CountField['nameLocale'], aliasesFlg: boolean) => {
if (name == null) return null if (name == null) return null
const language = languageUIMessage (name.languageCode, locale).text const language = languageUIMessage (name.languageCode, locale).text
const scripts = new Set ((definition.nameColumns ?? [])
.filter (field => field.nameLocale?.languageCode === name.languageCode)
.map (field => field.nameLocale!.scriptCode.toLowerCase ()))
const label = aliasesFlg ? 'counts:localeAliases' : 'counts:localeName' const label = aliasesFlg ? 'counts:localeAliases' : 'counts:localeName'
return { label: scripts.size > 1 ? `${label}WithScript` : label, return { label: name.scriptCode == null ? label : `${label}WithScript`,
labelValues: { language, script: name.scriptCode } } labelValues: { language, script: name.scriptCode ?? '' } }
} }
const columns = (fields: CountField[]): CountField[] => fields.map (field => ({ const columns = (fields: CountField[]): CountField[] => fields.map (field => ({
...field, ...heading (field.nameLocale, field.multipleValues === true), ...field, ...heading (field.nameLocale, field.multipleValues === true),