diff --git a/backend/app/controllers/counts_tables_controller.rb b/backend/app/controllers/counts_tables_controller.rb index f3620c3..df0c287 100644 --- a/backend/app/controllers/counts_tables_controller.rb +++ b/backend/app/controllers/counts_tables_controller.rb @@ -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 diff --git a/backend/app/services/counts_candidates.rb b/backend/app/services/counts_candidates.rb new file mode 100644 index 0000000..eebf002 --- /dev/null +++ b/backend/app/services/counts_candidates.rb @@ -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 diff --git a/backend/app/services/counts_condition.rb b/backend/app/services/counts_condition.rb index 7ceccc7..5d2ec5f 100644 --- a/backend/app/services/counts_condition.rb +++ b/backend/app/services/counts_condition.rb @@ -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 == '' diff --git a/backend/app/services/counts_pivot_layout.rb b/backend/app/services/counts_pivot_layout.rb index 6b38f6a..e523a34 100644 --- a/backend/app/services/counts_pivot_layout.rb +++ b/backend/app/services/counts_pivot_layout.rb @@ -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 diff --git a/backend/app/services/counts_pivot_totals.rb b/backend/app/services/counts_pivot_totals.rb index d7aa15c..7986d31 100644 --- a/backend/app/services/counts_pivot_totals.rb +++ b/backend/app/services/counts_pivot_totals.rb @@ -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 diff --git a/backend/app/services/counts_population.rb b/backend/app/services/counts_population.rb index bc77147..c3d8fb4 100644 --- a/backend/app/services/counts_population.rb +++ b/backend/app/services/counts_population.rb @@ -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 diff --git a/backend/app/services/counts_resource_definition.rb b/backend/app/services/counts_resource_definition.rb index 50e9131..cbe7f0b 100644 --- a/backend/app/services/counts_resource_definition.rb +++ b/backend/app/services/counts_resource_definition.rb @@ -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', diff --git a/backend/app/services/counts_table_export.rb b/backend/app/services/counts_table_export.rb index b9dd438..16bdbe7 100644 --- a/backend/app/services/counts_table_export.rb +++ b/backend/app/services/counts_table_export.rb @@ -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 diff --git a/backend/app/services/counts_table_query.rb b/backend/app/services/counts_table_query.rb index 99aeeca..6895572 100644 --- a/backend/app/services/counts_table_query.rb +++ b/backend/app/services/counts_table_query.rb @@ -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, diff --git a/backend/app/services/query_state.rb b/backend/app/services/query_state.rb index 37c9db4..5002e30 100644 --- a/backend/app/services/query_state.rb +++ b/backend/app/services/query_state.rb @@ -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') diff --git a/backend/config/routes.rb b/backend/config/routes.rb index faecb5c..c99dc7a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -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' diff --git a/frontend/server.mjs b/frontend/server.mjs index 9dfcc76..0e4a1e5 100644 --- a/frontend/server.mjs +++ b/frontend/server.mjs @@ -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 ('', result?.status === 404 + ? template.replace ('', (result?.status === 404 || countsPage) ? '' : '') : template .replace ('lang="ja"', `lang="${result.locale}"`) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fc3e97..3feb7d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/posts', element: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/tags', element: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/nico_tags', element: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/tag_descriptions', element: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/tag_groups', element: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/materials', element: , - handle: { preload: CountsResourcePage.preload } }, + handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'more', element: , handle: { preload: MorePage.preload, sitemap: ['static'] } }, { path: '*', element: , handle: { notFound: true } }] @@ -224,8 +225,11 @@ const matchPageRoutes = (pathname: string) => export const preloadPageRoute = async ( pathname: string, locale: LocaleCode, + intent: 'navigation' | 'prefetch' = 'navigation', ): Promise => { + 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 => { diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 01a4c01..27aefc7 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -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 (({ 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 (({ ev.preventDefault () + if (isCountsPath (new URL (url).pathname)) + { + navigate (path, { replace, ...(state && { state }) }) + return + } + flushSync (() => { setOverlay (true) }) diff --git a/frontend/src/components/counts/CountsAdditionalConditions.tsx b/frontend/src/components/counts/CountsAdditionalConditions.tsx index 27cc5b5..b7ab3b5 100644 --- a/frontend/src/components/counts/CountsAdditionalConditions.tsx +++ b/frontend/src/components/counts/CountsAdditionalConditions.tsx @@ -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
- - +
} @@ -99,7 +124,7 @@ const CountsAdditionalConditions = ({ params, update }: { const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes] const namespace = type === 'boolean' ? 'truthValues' : 'events' return } if (type === 'datetime') - return patch (index, { [key]: value ?? '' })}/> return patch (index, { [key]: event.target.value })}/> } @@ -130,24 +155,32 @@ const CountsAdditionalConditions = ({ params, update }: { + {countsSetOperators.includes (condition.operator) ? ( + 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 })}/>) : (
{valueInput (condition, index, 'value')} {condition.operator === 'range' && <> {valueInput (condition, index, 'end')} } -
+ )}
- diff --git a/frontend/src/components/counts/CountsCandidatePicker.tsx b/frontend/src/components/counts/CountsCandidatePicker.tsx new file mode 100644 index 0000000..96853a2 --- /dev/null +++ b/frontend/src/components/counts/CountsCandidatePicker.tsx @@ -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 = { + 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
+
+ setDraft (event.target.value)} + onKeyDown={event => { + if (event.key !== 'Enter') return + event.preventDefault () + setSearch (draft) + setPage (1) + }}/> + +
+ {dynamic && } +
+ {label ('pattern.columns')} + {available.map (column => )} +
+

{label ('pattern.value')}{':'} + {label (dynamic ? 'columns.resourceId' : 'common:categoryLabel')} +

+ + {errorFlg &&

{label ('common:fetchError')}

} +
+ + + {visible.map (column => )} + + {rows.map (row => + + {visible.map (column => )} + )} +
{label ('pattern.selection')} + {label (headings[column])}
= 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 + })}/> + {cell (row, column)}
+
+ {dynamic &&
+ + {page} + +
} +
+} + + +export default CountsCandidatePicker diff --git a/frontend/src/components/counts/CountsConditionValues.tsx b/frontend/src/components/counts/CountsConditionValues.tsx new file mode 100644 index 0000000..e6fa713 --- /dev/null +++ b/frontend/src/components/counts/CountsConditionValues.tsx @@ -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 => }) + } + if (condition.input === 'list' || field.candidateSource === 'tags') + return + 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 setDraft (value ?? '')}/> + if (field.type === 'boolean' || field.choices != null) + { + const choices = field.choices ?? ['true', 'false'].map (value => ({ + value, label: `counts:truthValues.${value}` })) + return + } + return 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
+ {values.map ((value, index) => + {display (value)} + )} + {input ()} + + +
+} + + +export default CountsConditionValues diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx index 4c4e625..37247b7 100644 --- a/frontend/src/components/counts/CountsSummaryResults.tsx +++ b/frontend/src/components/counts/CountsSummaryResults.tsx @@ -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 = ( {rowHeaders.map ((row, index) => {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 : = 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"> - {count (row, column)} + + {count (row, column)} )} )} diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index ecbb831..476e4b1 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -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 diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index 498ab36..d863a0f 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -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 }[] diff --git a/frontend/src/lib/countsDefinitions.ts b/frontend/src/lib/countsDefinitions.ts index a0438a0..b9b19ae 100644 --- a/frontend/src/lib/countsDefinitions.ts +++ b/frontend/src/lib/countsDefinitions.ts @@ -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 => 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 => 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, diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index e6dc7d8..db146ac 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -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 => { const u = new URL (urlLike, location.origin) + if (isCountsPath (u.pathname)) return if (u.origin !== location.origin) return diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index e7828fc..b570657 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 } diff --git a/frontend/src/lib/queryState.ts b/frontend/src/lib/queryState.ts index cd7c9c3..432f2e2 100644 --- a/frontend/src/lib/queryState.ts +++ b/frontend/src/lib/queryState.ts @@ -1,7 +1,8 @@ // Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON. export type QueryRecord = Record const schemas: Record = { - 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 = ( 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 => { 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) } diff --git a/frontend/src/lib/routeLocales.ts b/frontend/src/lib/routeLocales.ts index 7c16a66..88ea678 100644 --- a/frontend/src/lib/routeLocales.ts +++ b/frontend/src/lib/routeLocales.ts @@ -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 prepare?: (request: PagePreparation) => Promise switchLocale?: (request: LocaleSwitchRequest) => Promise @@ -33,3 +36,9 @@ export type PageRouteHandle = { export type PageRoute = { handle?: PageRouteHandle } & ( Omit | (Omit & { children?: PageRoute[] })) + + +export const isCountsPath = (pathname: string): boolean => { + const path = unlocalisedPath (pathname).toLowerCase () + return path === '/counts' || path.startsWith ('/counts/') +} diff --git a/frontend/src/lib/sitemap.server.ts b/frontend/src/lib/sitemap.server.ts index e3b7a77..8b2cb54 100644 --- a/frontend/src/lib/sitemap.server.ts +++ b/frontend/src/lib/sitemap.server.ts @@ -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)] diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json index c675e8b..6318aa2 100644 --- a/frontend/src/locales/ja/counts.json +++ b/frontend/src/locales/ja/counts.json @@ -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": "異動",