このコミットが含まれているのは:
2026-10-28 00:28:29 +09:00
コミット d6cf992622
27個のファイルの変更、630行の追加、60行の削除
+8
ファイルの表示
@@ -20,4 +20,12 @@ class CountsTablesController < ApplicationController
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
def candidates
resolve_locale!
render json: CountsCandidates.new(request.query_parameters.merge(
'resource' => request.path_parameters[:resource])).call
rescue ArgumentError, TypeError, KeyError
head :bad_request
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
# Bounded, authorised candidates at the same recorded point as the Counts query.
class CountsCandidates
KEYS = ['resource', 'field', 'query', 'category', 'page', 'locale', 'at',
'execution_at', 'timezone'].freeze
LIMIT = 20
def initialize(params)
QueryState.validate!(params, KEYS)
raise ArgumentError unless params.fetch('resource') == 'posts'
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
@params = params
@page = Integer(params.fetch('page', '1'), 10)
raise ArgumentError unless @page.between?(1, 100_000)
raise ArgumentError if params.fetch('query', '').bytesize > 1000
category = params['category']
raise ArgumentError if category.present? && !Tag.categories.key?(category)
end
def call
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
.merge('definition' => 'latest', 'resource' => 'tags')
internal = CountsPopulation.new(options)
external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
'execution_at' => internal.at.iso8601(6)))
language = Locale.find_by!(code: @params.fetch('locale', 'ja')).language_code
connection = TagVersion.connection
path = connection.quote("$.#{ language.to_json }.name")
name = "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(v.names, #{ path })), v.name)"
sources = [[internal, 'internal', name, 'v.category'],
[external, 'external', 'v.name', 'NULL']].map do |population, kind, title, category|
population.scope.except(:select).select(Arel.sql(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"#{ title } AS name, #{ category } AS category, '#{ kind }' AS kind")).to_sql
end
clauses = []
binds = []
if @params['query'].present?
clauses << '(name LIKE ? OR CAST(id AS CHAR) = ?)'
binds += ["%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%", @params['query']]
end
if @params['category'].present?
clauses << 'category = ?'
binds << @params['category']
end
where = clauses.empty? ? '' : "WHERE #{ clauses.join(' AND ') }"
sql = <<~SQL
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sources.join(' UNION ALL ') }) choices
#{ where } ORDER BY kind, id LIMIT #{ LIMIT + 1 } OFFSET #{ (@page - 1) * LIMIT }
SQL
rows = connection.select_all(ApplicationRecord.sanitize_sql_array([sql, *binds])).to_a
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
execution_at: internal.at.iso8601(6) }
end
end
+83 -2
ファイルの表示
@@ -44,12 +44,31 @@ class CountsCondition
result.find { _1[:key] == 'tags_json' }.merge!(type: :string, multiple: true, collection: true,
operators: CountsResourceDefinition::OPERATORS.fetch(:string))
end
result + [{ key: 'user.id', type: :integer, nullable: true,
result += [{ key: 'user.id', type: :integer, nullable: true,
labels: ['history:actor', 'counts:columns.resourceId'],
operators: CountsResourceDefinition::OPERATORS.fetch(:integer) },
{ key: 'user.name', type: :string, nullable: true,
labels: ['history:actor', 'history:name'],
operators: CountsResourceDefinition::OPERATORS.fetch(:string) }]
result.each do |field|
next if field[:type] == :interval
if field[:multiple]
field[:operators] = field[:operators] - ['in', 'not_in'] + ['any', 'all', 'none']
end
field[:input_types] = ['single', 'multiple']
if ['category', 'tag_category'].include?(field[:key].split('.').last)
field[:candidate_source] = 'categories'
field[:choices] = Tag.categories.keys.map { |value|
{ value:, label: "common:categories.#{ value }" }
}
elsif definition.resource == 'posts' && ['tags', 'tags_json'].include?(field[:key])
field[:candidate_source] = 'tags'
field[:set_type] = 'tag_reference'
end
field[:input_types] << 'list' if field[:candidate_source]
end
result
end
def initialize(population)
@@ -61,11 +80,17 @@ class CountsCondition
def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash)
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
raise ArgumentError if (entry.keys - allowed).any?
field = @definition.filter_metadata.find { _1[:key] == entry.fetch('field') }
raise ArgumentError unless field && field[:operators].include?(entry.fetch('operator'))
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
input = entry.fetch('input', 'single')
raise ArgumentError unless ['single', 'multiple', 'list'].include?(input)
if input == 'list' && !field[:candidate_source]
raise ArgumentError
end
values = [entry['value'], entry['end']]
raise ArgumentError unless values.all? { _1.nil? || (_1.is_a?(String) && _1.bytesize <= 8192) }
if entry['operator'] != 'range' && !values[1].nil? && values[1] != ''
@@ -84,6 +109,10 @@ class CountsCondition
end
return scope.where(Arel.sql(sql))
end
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
return set_condition(scope, entry, field)
end
raise ArgumentError if entry.key?('values') || input != 'single'
return scope if values.all? { _1.nil? || _1 == '' }
parsed = values.map { parse(_1, type) }
@@ -122,6 +151,58 @@ class CountsCondition
private
def set_condition(scope, entry, field)
return scope unless entry.key?('values')
values = entry['values']
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
parsed = values.map do |value|
unless (value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value))
raise ArgumentError
end
raise ArgumentError if value.to_s.bytesize > 8192
if field[:set_type] == 'tag_reference'
raise ArgumentError unless value.is_a?(String) &&
value.match?(/\A(?:internal|external):[1-9][0-9]{0,17}\z/)
value
else
raise ArgumentError if value == '' && ![:string, :enum].include?(field[:type])
result = value == '' ? '' : parse(value.to_s, field[:type])
validate_enum!(field[:key], result) if field[:type] == :enum
result
end
end
raise ArgumentError unless parsed.uniq == parsed
operator = entry['operator']
if parsed.empty?
return ['in', 'any'].include?(operator) ? scope.none : scope
end
source, expression = source_for(field[:key])
if field[:set_type] == 'tag_reference'
source = post_tag_source
id = "COALESCE(#{ scalar('tag_snapshot.item', 'tag_id', :integer) }, " +
"#{ scalar('tag_snapshot.item', 'external_tag_id', :integer) })"
expression = "CONCAT(CASE WHEN #{ scalar('tag_snapshot.item', 'tag_id') } IS NULL " +
"THEN 'external:' ELSE 'internal:' END, #{ id })"
end
predicates = parsed.map do |_value|
clause = "#{ expression } = ?"
clause = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ clause })" if source
clause
end
conjunction = operator == 'all' ? ' AND ' : ' OR '
predicate = "(#{ predicates.join(conjunction) })"
if ['not_in', 'none'].include?(operator)
predicate = source ? "NOT #{ predicate }" :
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
end
scope.where(predicate, *parsed)
end
def parse(value, type)
return nil if value.nil? || value == ''
+24 -1
ファイルの表示
@@ -10,7 +10,21 @@ class CountsPivotLayout
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
end
def self.headers(values, depths, next_values: nil, grand: false)
def self.label_key(row_count, column_count, row_depth, column_depth)
total = row_count + column_count
collapsed = total - row_depth - column_depth
return nil if collapsed.zero?
return 'grandTotal' if total >= 3 && collapsed == total
if (collapsed == 1 &&
((row_count > 1 && row_depth < row_count) ||
(column_count > 1 && column_depth < column_count)))
return 'subtotal'
end
'singleTotal'
end
def self.headers(values, depths, next_values: nil, grand: false, axis_count: nil,
other_count: 0, direction: :row)
result = []
values.each_with_index do |tuple, index|
result << { values: tuple, kind: 'data' }
@@ -23,6 +37,15 @@ class CountsPivotLayout
end
end
result << { values: [], kind: 'grand_total' } if grand
count = axis_count || values.first&.length || 0
result.each do |header|
depth = header[:values].length
label = if header[:kind] != 'data'
direction == :row ? label_key(count, other_count, depth, other_count) :
label_key(other_count, count, other_count, depth)
end
header.merge!(depth:, axis_count: count, label_key: label)
end
result
end
end
+3
ファイルの表示
@@ -53,6 +53,9 @@ class CountsPivotTotals
column_depth = record['column_depth'].to_i
{ row: (0...row_depth).map { record["v#{ _1 }"] },
column: (0...column_depth).map { record["v#{ row_depth + _1 }"] },
row_depth:, column_depth:,
label_key: CountsPivotLayout.label_key(@axes.rows.length, @axes.columns.length,
row_depth, column_depth),
count: record['count'].to_i }
end
end
+15 -1
ファイルの表示
@@ -103,7 +103,21 @@ class CountsPopulation
conditions = QueryState.records(params, 'conditions')
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
conditions.each { apply_condition(_1) }
values = QueryState.records(params, 'condition_values')
values.each do |entry|
index = entry.fetch('condition')
raise ArgumentError unless index.between?(0, conditions.length - 1)
raise ArgumentError unless conditions[index]['values'] == true
end
conditions.each_with_index do |entry, index|
if entry.key?('values')
raise ArgumentError unless entry['values'] == true
entry = entry.merge('values' => values.select { _1['condition'] == index }
.map { _1.fetch('value') })
end
apply_condition(entry)
end
drills = QueryState.records(params, 'drill')
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
+4 -3
ファイルの表示
@@ -46,9 +46,10 @@ class CountsResourceDefinition
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
OPERATORS = {
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
string: ['eq', 'ne', 'like', 'in', 'not_in'],
enum: ['eq', 'ne', 'in', 'not_in'], boolean: ['eq', 'ne', 'in', 'not_in'],
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range', 'in', 'not_in'],
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range', 'in', 'not_in'],
interval: ['range'] }.freeze
LABELS = {
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
+7 -4
ファイルの表示
@@ -143,8 +143,8 @@ class CountsTableExport
end
def total_label(header)
key = header[:kind] == 'grand_total' ?
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
key = header[:label_key] || (header[:kind] == 'grand_total' ?
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal')
@labels.fetch(key, '')
end
@@ -152,9 +152,10 @@ class CountsTableExport
values = header[:values].each_with_index.map { |value, index|
summary_value(value, index, page)
}
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
if @previous_pivot_row
values.each_index do |index|
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
if (@previous_pivot_row[:values].length > index &&
header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1))
values[index] = ''
end
end
@@ -234,6 +235,8 @@ class CountsTableExport
{ row: row[:values], column: column[:values], kind:,
row_kind: row[:kind], column_kind: column[:kind],
row_depth: row[:values].length, column_depth: column[:values].length,
label_key: CountsPivotLayout.label_key(@query.axes.rows.length, @query.axes.columns.length,
row[:values].length, column[:values].length),
count: @pivot_lookup.fetch([row[:values], column[:values]], 0) }
end
+11 -5
ファイルの表示
@@ -5,7 +5,8 @@ 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'].freeze
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale',
'measures', 'column_order', 'condition_values'].freeze
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
@@ -145,14 +146,19 @@ class CountsTableQuery
end
column_depths = CountsPivotLayout.depths(column_keys, column_sort, measures: false)
row_headers = CountsPivotLayout.headers(row_values, row_depths,
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?,
axis_count: axes.rows.length, other_count: axes.columns.length)
if axes.rows.empty? && axes.columns.any?
row_headers = [{ values: [], kind: 'grand_total' }]
row_headers = CountsPivotLayout.headers([[]], [], axis_count: 0,
other_count: axes.columns.length).map { _1.merge(kind: 'grand_total') }
end
column_headers = CountsPivotLayout.headers(column_values, column_depths,
grand: axes.columns.any?)
grand: axes.columns.any?, axis_count: axes.columns.length,
other_count: axes.rows.length, direction: :column)
if axes.columns.empty? && axes.rows.any?
column_headers = [{ values: [], kind: 'grand_total' }]
column_headers = CountsPivotLayout.headers([[]], [], axis_count: 0,
other_count: axes.rows.length, direction: :column)
.map { _1.merge(kind: 'grand_total') }
end
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
{ group_count: total,
+5 -4
ファイルの表示
@@ -1,7 +1,8 @@
# Public query grammar shared with frontend/src/lib/queryState.ts.
class QueryState
SCHEMAS = {
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
'conditions' => ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
'condition_values' => ['condition', 'value', 'value_type'],
'drill' => ['field', 'grain', 'value', 'value_type'],
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
'column_order' => ['value', 'value_type'],
@@ -40,11 +41,11 @@ class QueryState
_root, index, field = name.split('.')
record = records[index.to_i] ||= {}
if ['blank', 'negative', 'missing'].include?(field)
if ['blank', 'negative', 'missing', 'values'].include?(field)
raise ArgumentError unless ['0', '1'].include?(value)
value = value == '1'
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
elsif ['tag_id', 'external_tag_id', 'axis', 'condition'].include?(field)
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
value = value.to_i
@@ -56,7 +57,7 @@ class QueryState
records.sort.map do |_index, record|
type = record.delete('value_type')
if ['drill', 'measures', 'column_order'].include?(key)
if ['drill', 'measures', 'column_order', 'condition_values'].include?(key)
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
raise ArgumentError unless type == 'null' || record.key?('value')
+1
ファイルの表示
@@ -1,5 +1,6 @@
Rails.application.routes.draw do
get 'counts/definitions/:resource', to: 'counts_tables#definition'
get 'counts/definitions/:resource/candidates', to: 'counts_tables#candidates'
get 'counts/query', to: 'counts_tables#index'
post 'counts/export/jobs', to: 'counts_exports#create_job'
get 'counts/export/jobs/:token', to: 'counts_exports#job_status'
+4 -2
ファイルの表示
@@ -5,7 +5,7 @@ import { request as requestHTTPS } from 'node:https'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { render, renderSitemap } from './dist/server/entry-server.js'
import { isCountsPath, render, renderSitemap } from './dist/server/entry-server.js'
const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
const template = await readFile (path.join (root, 'index.html'), 'utf8')
@@ -61,6 +61,8 @@ const server = createServer (async (request, response) => {
{
const url = new URL (request.url, origin)
const pathname = decodeURIComponent (url.pathname)
const countsPage = isCountsPath (pathname)
if (countsPage) response.setHeader ('X-Robots-Tag', 'noindex')
if (pathname === '/sitemap.xml' || pathname.startsWith ('/sitemap-'))
{
const result = await renderSitemap (url.pathname, origin, apiBaseURL)
@@ -108,7 +110,7 @@ const server = createServer (async (request, response) => {
return
}
const html = result?.html == null
? template.replace ('<!-- helmet -->', result?.status === 404
? template.replace ('<!-- helmet -->', (result?.status === 404 || countsPage)
? '<meta name="robots" content="noindex" data-rh="true">' : '')
: template
.replace ('lang="ja"', `lang="${result.locale}"`)
+12 -8
ファイルの表示
@@ -23,6 +23,7 @@ import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { isCountsPath } from '@/lib/routeLocales'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale'
@@ -199,19 +200,19 @@ export const pageRoutes = (
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/nico_tags', element: <CountsResourcePage resource="nico_tags"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/tag_descriptions', element: <CountsResourcePage resource="tag_descriptions"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/tag_groups', element: <CountsResourcePage resource="tag_groups"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/materials', element: <CountsResourcePage resource="materials"/>,
handle: { preload: CountsResourcePage.preload } },
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
@@ -224,8 +225,11 @@ const matchPageRoutes = (pathname: string) =>
export const preloadPageRoute = async (
pathname: string,
locale: LocaleCode,
intent: 'navigation' | 'prefetch' = 'navigation',
): Promise<void> => {
if (intent === 'prefetch' && isCountsPath (pathname)) return
await Promise.all ((matchPageRoutes (pathname) ?? [])
.filter (match => intent === 'navigation' || match.route.handle?.prefetch !== false)
.map (match => match.route.handle?.preload?.(locale)))
}
@@ -262,7 +266,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const location = useLocation ()
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname)
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
+8
ファイルの表示
@@ -7,6 +7,7 @@ import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { isCountsPath } from '@/lib/routeLocales'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { currentRenderContext } from '@/lib/renderContext'
@@ -53,6 +54,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
if (isCountsPath (new URL (url).pathname)) return true
try
{
await prefetchForURL (qc, url)
@@ -94,6 +96,12 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (isCountsPath (new URL (url).pathname))
{
navigate (path, { replace, ...(state && { state }) })
return
}
flushSync (() => {
setOverlay (true)
})
+43 -10
ファイルの表示
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'
import CountsConditionValues from '@/components/counts/CountsConditionValues'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import { countsEventTypes, countsFieldLabel, countsFilterFields,
useCountsDefinition }
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
countsSetOperators, useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
@@ -33,12 +34,14 @@ const ConditionEditor = (
})
const { field, operator } = condition
const operators = fields.find (item => item.key === field)?.operators ?? []
const inputs = countsConditionInputs (fields.find (item => item.key === field), operator)
useEffect (() => {
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
onSelect: () => save (condition) }])
}, [controls, condition, initial, label, save])
return <div className="space-y-2">
<select value={field} aria-label={message ('field').text} className={inputClass ()}
<select value={field} aria-label={message ('field').text}
className={inputClass (false, 'h-9 w-full')}
onChange={event => {
const field = event.target.value
const operator = fields.find (item => item.key === field)?.operators[0]
@@ -47,17 +50,39 @@ const ConditionEditor = (
{fields.filter (field => field.operators.length > 0).map (field => (
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
</select>
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
<select value={operator} aria-label={message ('operator').text}
className={inputClass (false, 'h-9 w-full')}
onChange={event => setCondition (previous => {
const operator = event.target.value
const next = { ...previous, operator }
if (operator !== 'range') delete next.end
if ((operator === 'like') !== (previous.operator === 'like')) delete next.value
const choices = countsConditionInputs (fields.find (item => item.key === field), operator)
if (countsSetOperators.includes (operator))
{
delete next.value
delete next.end
next.input = choices.includes (previous.input ?? 'single') ? previous.input : choices[0]
}
else
{
delete next.values
delete next.input
}
return next
})}>
{operators.map (key => <option key={key} value={key}>
{message (`operators.${key}`).text}</option>)}
</select>
<label className="block text-sm">{label ('pattern.input')}
<select value={condition.input ?? inputs[0]}
className={inputClass (false, 'h-9 w-full')}
onChange={event => setCondition (previous => ({ ...previous,
input: event.target.value as CountsCondition['input'] }))}>
{inputs.map (input => <option key={input} value={input}>
{message (`pattern.${input}`).text}</option>)}
</select>
</label>
</div>
}
@@ -99,7 +124,7 @@ const CountsAdditionalConditions = ({ params, update }: {
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
const namespace = type === 'boolean' ? 'truthValues' : 'events'
return <select value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text}
lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}>
@@ -110,13 +135,13 @@ const CountsAdditionalConditions = ({ params, update }: {
</select>
}
if (type === 'datetime')
return <DateTimeField precision="second" className="h-7 w-48 min-w-0 max-w-full py-1"
return <DateTimeField precision="second" className="h-9 w-48 min-w-0 max-w-full py-1"
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/>
return <input value={condition[key] ?? ''} disabled={condition.blank}
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'h-7 w-36 min-w-0 max-w-full py-1')}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
}
@@ -130,24 +155,32 @@ const CountsAdditionalConditions = ({ params, update }: {
<Button type="button" variant="ghost" size="sm" onClick={() => edit (index)}
aria-label={`${fieldName (condition.field)} ${message (
`operators.${condition.operator}`).text}`}
className="h-7 min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
className="min-w-0 max-w-full justify-start gap-2 whitespace-normal px-1 text-left">
<span className="min-w-0 break-words">{fieldName (condition.field)}</span>
{condition.operator !== 'range' && <span aria-hidden="true" className="shrink-0">
{label (`operatorSymbols.${condition.operator}`)}</span>}
</Button>
{countsSetOperators.includes (condition.operator) ? (
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
condition={condition}
field={metadata.find (field => field.key === condition.field)!}
definition={definition} params={params}
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
sum + (position === index ? 0 : item.values?.length ?? 0), 0))}
change={values => patch (index, { values })}/>) : (
<div className="inline-flex min-w-0 max-w-full items-center gap-1">
{valueInput (condition, index, 'value')}
{condition.operator === 'range' && <>
<span aria-hidden="true" className="shrink-0">{label ('operatorSymbols.range')}</span>
{valueInput (condition, index, 'end')}
</>}
</div>
</div>)}
<div className="inline-flex shrink-0 items-center gap-1">
<label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/>
{label ('blank')}</label>
<Button type="button" variant="ghost" size="sm" className="h-7 px-1"
<Button type="button" variant="ghost" size="sm" className="px-1"
aria-label={message ('remove').text}
onClick={() => write (conditions.filter ((_, position) => index !== position))}>
×</Button>
+139
ファイルの表示
@@ -0,0 +1,139 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { countsUseLatest } from '@/lib/counts'
import { fetchCountsCandidates } from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { inputClass } from '@/lib/utils'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions'
const CountsCandidatePicker = (
{ field, definition, params, initial, maximum, controls, save }: {
field: CountsFieldMetadata; params: URLSearchParams
definition: CountsDefinition
initial: (string | number | boolean)[]; maximum: number
controls: DialogueFormControls; save: (values: string[]) => void },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const [selected, setSelected] = useState (() => new Set (initial.map (String)))
const [columns, setColumns] = useState (['id', 'name', 'category', 'kind'])
const [draft, setDraft] = useState ('')
const [search, setSearch] = useState ('')
const [category, setCategory] = useState ('')
const [page, setPage] = useState (1)
const point = useRef (params.get ('execution_at'))
const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
for (const key of ['timezone', 'at', 'execution_at'])
if (params.has (key)) request.set (key, params.get (key)!)
if (countsUseLatest (params)) request.delete ('at')
if (category) request.set ('category', category)
const dynamic = field.candidateSource === 'tags'
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
queryFn: ({ signal }) => {
const pinned = new URLSearchParams (request)
if (point.current != null) pinned.set ('execution_at', point.current)
return fetchCountsCandidates (pinned, locale, signal)
}, retry: false })
useEffect (() => {
if (data != null) point.current = data.executionAt
}, [data])
useEffect (() => {
controls.setActions ([{ label: label ('pattern.selection'),
onSelect: () => save ([...selected]) }])
}, [controls, label, save, selected])
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
value: choice.value, name: message (choice.label).text }))
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
row.name?.includes (search))
const headings: Record<string, string> = {
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
kind: 'columns.kind' }
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
const visible = available.filter (column => columns.includes (column))
const categories = definition.filterFields?.find (field =>
field.candidateSource === 'categories')?.choices ?? []
const cell = (row: CountsCandidate, column: string) => {
if (column === 'category') return row.category == null ? ''
: message (`common:categories.${row.category}`).text
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
return column === 'id' ? row.id : row.name
}
return <div className="min-w-0 space-y-2">
<div className="flex min-w-0 items-center gap-2">
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
aria-label={message ('common:search').text} value={draft}
onChange={event => setDraft (event.target.value)}
onKeyDown={event => {
if (event.key !== 'Enter') return
event.preventDefault ()
setSearch (draft)
setPage (1)
}}/>
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
{label ('common:search')}</Button>
</div>
{dynamic && <select className={inputClass (false, 'h-9 w-full')}
aria-label={message ('common:categoryLabel').text} value={category}
onChange={event => { setCategory (event.target.value); setPage (1) }}>
<option value=""/>
{categories.map (choice => <option key={choice.value} value={choice.value}>
{message (choice.label).text}</option>)}
</select>}
<fieldset className="flex flex-wrap items-center gap-2 text-sm">
<legend>{label ('pattern.columns')}</legend>
{available.map (column => <label key={column} className="inline-flex items-center gap-1">
<input type="checkbox" checked={columns.includes (column)} onChange={event =>
setColumns (previous => event.target.checked ? [...previous, column]
: previous.filter (value => value !== column))}/>{label (headings[column])}
</label>)}
</fieldset>
<p className="text-sm">{label ('pattern.value')}{':'}
{label (dynamic ? 'columns.resourceId' : 'common:categoryLabel')}
</p>
<Button type="button" variant="ghost" size="sm" onClick={() => setSelected (new Set ())}>
{label ('shared:actions.reset')}</Button>
{errorFlg && <p role="alert">{label ('common:fetchError')}</p>}
<div className="max-h-[40dvh] overflow-auto" aria-busy={fetching}>
<table className="w-full text-left text-sm">
<thead><tr><th className="p-2">{label ('pattern.selection')}</th>
{visible.map (column => <th key={column} className="p-2">
{label (headings[column])}</th>)}
</tr></thead>
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
aria-label={row.name ?? String (row.id ?? '')}
disabled={!selected.has (row.value) && selected.size >= maximum}
onChange={event => setSelected (previous => {
const next = new Set (previous)
if (event.target.checked) next.add (row.value)
else next.delete (row.value)
return next
})}/></td>
{visible.map (column => <td key={column} className="break-words p-2">
{cell (row, column)}</td>)}
</tr>)}</tbody>
</table>
</div>
{dynamic && <div className="flex items-center justify-between gap-2">
<Button type="button" variant="outline" disabled={page === 1 || fetching}
onClick={() => setPage (previous => previous - 1)}>
{label ('common:pagination.previous')}</Button>
<span>{page}</span>
<Button type="button" variant="outline" disabled={!data?.more || fetching}
onClick={() => setPage (previous => previous + 1)}>
{label ('common:pagination.next')}</Button>
</div>}
</div>
}
export default CountsCandidatePicker
+84
ファイルの表示
@@ -0,0 +1,84 @@
import { useState } from 'react'
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
from '@/lib/countsDefinitions'
const CountsConditionValues = (
{ condition, field, definition, params, maximum, change }: {
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
params: URLSearchParams; maximum: number
change: (values: (string | number | boolean)[]) => void },
) => {
const { label, message } = useUITranslation ('counts')
const dialogue = useDialogue ()
const [draft, setDraft] = useState ('')
const values = condition.values ?? []
const pick = () => {
void dialogue.form ({ title: label (field.candidateSource === 'tags'
? 'pattern.link' : 'pattern.pattern'), size: 'large', footerLayout: 'inline',
body: controls => <CountsCandidatePicker field={field} definition={definition}
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
}
if (condition.input === 'list' || field.candidateSource === 'tags')
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
{label ('pattern.list')}{' ('}{values.length}{')'}</Button>
const append = () => {
if (draft === '' || values.length >= maximum
|| values.some (value => String (value) === draft))
return
change ([...values, draft])
setDraft ('')
}
const input = () => {
if (field.type === 'datetime')
return <DateTimeField precision="second" value={draft} disabled={condition.blank}
className="h-9 w-48 min-w-0 max-w-full" aria-label={message ('value').text}
onChange={value => setDraft (value ?? '')}/>
if (field.type === 'boolean' || field.choices != null)
{
const choices = field.choices ?? ['true', 'false'].map (value => ({
value, label: `counts:truthValues.${value}` }))
return <select value={draft} disabled={condition.blank}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
aria-label={message ('value').text} onChange={event => setDraft (event.target.value)}>
<option value=""/>
{choices.map (choice => <option key={choice.value} value={choice.value}>
{message (choice.label).text}</option>)}
</select>
}
return <input value={draft} disabled={condition.blank}
type={field.type === 'integer' ? 'number' : 'text'} aria-label={message ('value').text}
className={inputClass (false, 'h-9 w-36 min-w-0 max-w-full')}
onChange={event => setDraft (event.target.value)}/>
}
const display = (value: string | number | boolean) => {
const choice = field.choices?.find (choice => choice.value === String (value))
if (choice != null) return message (choice.label).text
if (field.type === 'boolean') return message (`truthValues.${value}`).text
return String (value)
}
return <div className="flex min-w-0 max-w-full flex-wrap items-center gap-1">
{values.map ((value, index) => <span key={`${index}:${value}`}
className="inline-flex items-center gap-1">
{display (value)}<Button type="button" size="sm" variant="ghost"
aria-label={message ('remove').text} disabled={condition.blank}
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
</span>)}
{input ()}
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
<Button type="button" variant="ghost" disabled={condition.blank}
onClick={() => change ([])}>{label ('shared:actions.reset')}</Button>
</div>
}
export default CountsConditionValues
+15 -6
ファイルの表示
@@ -41,8 +41,8 @@ const CountsSummaryTable = (
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
countsSummaryValue (value, axis, index, data, locale)
const aggregateLabel = (header: CountsPivotHeader) => message (
header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal').text
header.labelKey ?? (header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal')).text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
const headingLevels = cols.length === 0 ? 1 : levels * 2
@@ -77,6 +77,8 @@ const CountsSummaryTable = (
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.count]))
const totalLabels = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.labelKey]))
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
(row.values.length === rows.length && column.values.length === cols.length)
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
@@ -101,16 +103,16 @@ const CountsSummaryTable = (
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
if (row.kind !== 'data') return 1
if (row.values.length <= axis) return 1
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
const previous = rowHeaders[index - 1]
if (previous?.kind === 'data'
if (previous != null && previous.values.length > axis
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
let span = 1
while (index + span < rowHeaders.length)
{
const next = rowHeaders[index + span]
if (next.kind !== 'data'
if (next.values.length <= axis
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
++span
}
@@ -246,8 +248,11 @@ const CountsSummaryTable = (
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
if (axis >= 0 && row.kind !== 'data' && axis > row.values.length) return null
const aggregate = axis >= 0 && row.kind !== 'data' && axis === row.values.length
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
return span === 0 ? null : <td key={offset} rowSpan={span}
colSpan={aggregate ? leading - axis : 1}
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
@@ -264,7 +269,11 @@ const CountsSummaryTable = (
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
<PrefetchLink to={drill (row, column)} aria-label={
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
: `${message (totalLabels.get (
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
{count (row, column)}</PrefetchLink>
</td>)}
</tr>)}</tbody>
</CountsSelectableTable>
+1
ファイルの表示
@@ -20,6 +20,7 @@ import type { RenderContext } from '@/lib/renderContext'
import type { User } from '@/types'
export { renderSitemap } from '@/lib/sitemap.server'
export { isCountsPath } from '@/lib/routeLocales'
type RenderedPage = {
status: number
+3 -2
ファイルの表示
@@ -58,7 +58,8 @@ export type CountsStateTotals = {
deprecatedTags: number | null
externalAssignments: number | null }
export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' }
kind: 'data' | 'subtotal' | 'grand_total'
depth?: number; axisCount?: number; labelKey?: string | null }
export type CountsResult = {
populationCount?: number
excludedCount?: number | null
@@ -70,7 +71,7 @@ export type CountsResult = {
rowHeaders?: CountsPivotHeader[]
columnHeaders?: CountsPivotHeader[]
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
count: number }[]
count: number; rowDepth?: number; columnDepth?: number; labelKey?: string | null }[]
executionAt?: string
columnValues?: (string | number | null)[][]
cells?: { values: (string | number | null)[]; count: number }[]
+30 -2
ファイルの表示
@@ -8,7 +8,8 @@ import type { CountField, CountsResource } from '@/lib/counts'
export type CountsAxis = { field: string; grain?: string }
export type CountsCondition = {
field: string; operator: string; value?: string; end?: string; blank?: boolean }
field: string; operator: string; value?: string; end?: string; blank?: boolean
input?: 'single' | 'multiple' | 'list'; values?: (string | number | boolean)[] }
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
| 'json' | 'boolean' | 'interval'
@@ -22,7 +23,9 @@ export const visibleCountsResources = [
export type CountsFieldMetadata = {
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
labels?: string[]; multiple?: boolean }
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
setType?: 'tag_reference'; inputTypes?: string[]
choices?: { value: string; label: string }[] }
export type CountsDefinition = {
fields: CountsFieldMetadata[]
outputFields?: CountField[]
@@ -44,6 +47,31 @@ export const useCountsDefinition = (): CountsDefinition => {
export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsDefinition> =>
apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
export type CountsCandidate = {
value: string; id?: number; name: string | null; category?: string | null
kind?: 'internal' | 'external' }
export type CountsCandidatesResult = {
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
export const fetchCountsCandidates = (
params: URLSearchParams,
locale: LocaleCode,
signal?: AbortSignal,
): Promise<CountsCandidatesResult> => apiGet (
'/counts/definitions/posts/candidates', {
params: Object.fromEntries (params), locale, signal })
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
export const countsConditionInputs = (
field: CountsFieldMetadata | undefined,
operator: string,
): ('single' | 'multiple' | 'list')[] => {
if (!countsSetOperators.includes (operator)) return ['single']
if (field?.candidateSource === 'tags') return ['list']
return field?.candidateSource == null ? ['multiple'] : ['list', 'multiple']
}
export const countsFilterFields = (
definition: CountsDefinition,
+5 -2
ファイルの表示
@@ -1,5 +1,3 @@
import { queryRecordParams } from '@/lib/queryState'
import { QueryClient } from '@tanstack/react-query'
import { match } from 'path-to-regexp'
import { getHelpArticle,
@@ -11,12 +9,16 @@ import { getHelpArticle,
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { queryRecordParams } from '@/lib/queryState'
import { isCountsPath } from '@/lib/routeLocales'
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiAutolinkCandidates,
fetchWikiPageByReference,
fetchWikiTag,
fetchWikiPages } from '@/lib/wiki'
import type { QueryClient } from '@tanstack/react-query'
import type { LocaleCode } from '@/lib/locales'
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
@@ -326,6 +328,7 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
const u = new URL (urlLike, location.origin)
if (isCountsPath (u.pathname)) return
if (u.origin !== location.origin)
return
+1
ファイルの表示
@@ -173,5 +173,6 @@ export const theatresKeys = {
export const countsKeys = {
definition: (resource: string) => ['counts', 'definition', resource] as const,
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
+37 -8
ファイルの表示
@@ -1,7 +1,8 @@
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
const schemas: Record<string, string[]> = {
conditions: ['field', 'operator', 'value', 'end', 'blank'],
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
condition_values: ['condition', 'value', 'value_type'],
drill: ['field', 'grain', 'value', 'value_type'],
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
column_order: ['value', 'value_type'],
@@ -23,8 +24,8 @@ export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[
const index = Number (parts[0])
const record = indexed.get (index) ?? { }
const field = parts[1]
record[field] = ['blank', 'negative', 'missing'].includes (field)
? value === '1' : (['tag_id', 'external_tag_id', 'axis'].includes (field)
record[field] = ['blank', 'negative', 'missing', 'values'].includes (field)
? value === '1' : (['tag_id', 'external_tag_id', 'axis', 'condition'].includes (field)
? Number (value) : value)
indexed.set (index, record)
}
@@ -52,11 +53,13 @@ export const setQueryRecords = (
if (value == null && field !== 'value') continue
if (!schemas[key]?.includes (field)) continue
const name = `${key}.${index}.${field}`
if (field === 'value' && ['drill', 'measures', 'column_order'].includes (key))
if (field === 'value'
&& ['drill', 'measures', 'column_order', 'condition_values'].includes (key))
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
if (value == null) continue
params.set (name, typeof value === 'boolean'
? (['blank', 'negative', 'missing'].includes (field) ? (value ? '1' : '0') : String (value))
? (['blank', 'negative', 'missing', 'values'].includes (field)
? (value ? '1' : '0') : String (value))
: String (value))
}
})
@@ -74,16 +77,34 @@ export const readCountState = <T,> (
const [field, grain] = token.split (':')
return grain == null ? { field } : { field, grain }
}) as T
return queryRecords (params, key) as T
const records = queryRecords (params, key)
if (key === 'conditions')
{
const values = queryRecords (params, 'condition_values')
return records.map ((record, index) => record.values === true
? { ...record, values: values.filter (entry => entry.condition === index)
.map (entry => entry.value) } : record) as T
}
return records as T
}
export const encodeCountState = (key: string, records: QueryRecord[]): string => {
export const encodeCountState = (key: string, records: Record<string, unknown>[]): string => {
if (key === 'rows' || key === 'cols')
return records.map (record => record.grain == null
? String (record.field) : `${record.field}:${record.grain}`).join (' ')
const params = new URLSearchParams ()
setQueryRecords (params, key, records)
const values: QueryRecord[] = []
const flat = records.map ((record, condition) => {
if (key !== 'conditions' || !Array.isArray (record.values)) return record as QueryRecord
const { values: selected, ...fields } = record
for (const value of selected as unknown[])
values.push ({ condition, value: value as string | number | boolean })
return { ...fields, values: true } as QueryRecord
})
setQueryRecords (params, key, flat)
if (key === 'conditions' && flat.some (record => record.values === true))
setQueryRecords (params, 'condition_values', values)
return params.toString ()
}
@@ -93,6 +114,14 @@ export const setCountState = (params: URLSearchParams, key: string, value: strin
{
const fields = new URLSearchParams (value)
setQueryRecords (params, key, queryRecords (fields, key))
if (key === 'conditions')
{
for (const name of [...params.keys ()])
if (name === 'condition_values' || name.startsWith ('condition_values.'))
params.delete (name)
if (fields.has ('condition_values'))
setQueryRecords (params, 'condition_values', queryRecords (fields, 'condition_values'))
}
}
else params.set (key, value)
}
+9
ファイルの表示
@@ -1,3 +1,5 @@
import { unlocalisedPath } from '@/lib/locales'
import type { QueryClient } from '@tanstack/react-query'
import type { IndexRouteObject, Location, NonIndexRouteObject, Params } from 'react-router-dom'
@@ -20,6 +22,7 @@ export type SitemapSource =
| 'descriptions' | 'tag_groups' | 'theatres'
export type PageRouteHandle = {
prefetch?: false
preload?: (locale: LocaleCode) => Promise<unknown>
prepare?: (request: PagePreparation) => Promise<number>
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
@@ -33,3 +36,9 @@ export type PageRouteHandle = {
export type PageRoute = { handle?: PageRouteHandle } & (
Omit<IndexRouteObject, 'handle'>
| (Omit<NonIndexRouteObject, 'handle' | 'children'> & { children?: PageRoute[] }))
export const isCountsPath = (pathname: string): boolean => {
const path = unlocalisedPath (pathname).toLowerCase ()
return path === '/counts' || path.startsWith ('/counts/')
}
+2
ファイルの表示
@@ -5,6 +5,7 @@ import { apiGet, isApiError } from '@/lib/api'
import { parseHelpEdition } from '@/lib/help'
import { fetchLocales, isLocaleCode, localeLanguage } from '@/lib/locales'
import { withRenderContext } from '@/lib/renderContext.server'
import { isCountsPath } from '@/lib/routeLocales'
import type { LocaleCode } from '@/lib/locales'
import type { PageRoute, SitemapSource } from '@/lib/routeLocales'
@@ -27,6 +28,7 @@ const sitemapRoutes = (locale: LocaleCode): SitemapRoute[] => {
|| (handle?.locale != null && handle.locale !== locale))
return []
const path = [parent, route.path].filter (Boolean).join ('/')
if (isCountsPath (`/${path}`)) return []
return [
...(handle?.sitemap ?? []).map (source => ({ path, source })),
...collect (route.children ?? [], path)]
+21
ファイルの表示
@@ -186,6 +186,11 @@
"hour": "時"
},
"operatorSymbols": {
"in": "IN",
"not_in": "NOT IN",
"any": "ANY",
"all": "ALL",
"none": "NONE",
"eq": "=",
"ne": "≠",
"gt": ">",
@@ -196,6 +201,11 @@
"range": "~"
},
"operators": {
"in": "IN",
"not_in": "NOT IN",
"any": "選択値のいずれかを含む",
"all": "選択値のすべてを含む",
"none": "選択値を一つも含まない",
"eq": "等しい",
"ne": "等しくない",
"gt": "より大きい",
@@ -205,6 +215,17 @@
"like": "パターン",
"range": "範囲"
},
"pattern": {
"input": "画面出力タイプ",
"single": "単一入力",
"multiple": "複数入力",
"list": "複数選択リスト",
"pattern": "データパターン",
"link": "データリンク",
"columns": "表示列",
"value": "適用値",
"selection": "選択"
},
"events": {
"create": "起票",
"update": "異動",