ファイル
btrc-hub/frontend/src/lib/countsDefinitions.ts
T
2026-10-29 12:30:42 +09:00

158 行
6.7 KiB
TypeScript

import { createContext, useContext } from 'react'
import { languageUIMessage, translateUIMessage } from '@/lib/i18n'
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { CountField, CountsResource } from '@/lib/counts'
export type CountsAxis = { field: string; grain?: string }
export type CountsCondition = {
field: string; operator: string; grain?: string; value?: string | number | boolean | null; end?: string; blank?: boolean
input?: 'single' | 'multiple' | 'list'; values?: (string | number | boolean)[] }
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
| 'json' | 'boolean' | 'interval'
export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
// Navigation visibility only; supported resources and direct URLs remain available.
// The external-tag entry currently uses Nico tag history.
export const visibleCountsResources = [
'posts', 'tags', 'nico_tags'] as const satisfies readonly CountsResource[]
export type CountsFieldMetadata = {
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
setType?: 'tag_reference'; inputTypes?: string[]
labelValues?: Record<string, string>
nameLocale?: CountField['nameLocale']
legacy?: boolean
choices?: { value: string; label: string }[] }
export type CountsDefinition = {
fields: CountsFieldMetadata[]
outputFields?: CountField[]
nameColumns?: CountField[]
columnAliases?: { key: string; value: string }[]
conditionAliases?: { key: string; value: string }[]
filterFields?: CountsFieldMetadata[]
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
axes: { latest: string[]; history: string[] }
grains: string[] }
export const CountsDefinitionContext = createContext<CountsDefinition | null> (null)
export const useCountsDefinition = (): CountsDefinition => {
const definition = useContext (CountsDefinitionContext)
if (definition == null) throw new Error ('Missing Counts definition')
return definition
}
const localiseCountsDefinition = (
definition: CountsDefinition, locale: LocaleCode,
): CountsDefinition => {
const heading = (name: CountField['nameLocale'], aliasesFlg: boolean) => {
if (name == null) return null
const language = languageUIMessage (name.languageCode, locale).text
const label = aliasesFlg ? 'counts:localeAliases' : 'counts:localeName'
return { label: name.scriptCode == null ? label : `${label}WithScript`,
labelValues: { language, script: name.scriptCode ?? '' } }
}
const columns = (fields: CountField[]): CountField[] => fields.map (field => ({
...field, ...heading (field.nameLocale, field.multipleValues === true),
...(field.children == null ? { } : { children: columns (field.children) }) }))
const metadata = (fields: CountsFieldMetadata[]): CountsFieldMetadata[] =>
fields.map (field => {
const aliasesFlg = field.key.split ('.').pop()?.startsWith ('aliases_') === true
const name = heading (field.nameLocale, aliasesFlg)
return name == null ? field : { ...field, labelValues: name.labelValues,
labels: field.labels?.map (label =>
['counts:localeName', 'counts:localeAliases'].includes (label) ? name.label : label) }
})
return { ...definition,
fields: metadata (definition.fields),
...(definition.outputFields == null ? { }
: { outputFields: columns (definition.outputFields) }),
...(definition.nameColumns == null ? { }
: { nameColumns: columns (definition.nameColumns) }),
...(definition.filterFields == null ? { }
: { filterFields: metadata (definition.filterFields) }) }
}
export const fetchCountsDefinition = (
resource: CountsResource, locale: LocaleCode = 'ja',
): Promise<CountsDefinition> => apiGet<CountsDefinition> (
`/counts/definitions/${encodeURIComponent (resource)}`, { locale })
.then (definition => localiseCountsDefinition (definition, locale))
export type CountsCandidate = {
value: string; id?: number; name: string | null; category?: string | null
names?: Record<string, unknown>
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,
): Record<string, CountsFieldType> => Object.fromEntries (
(definition.filterFields ?? definition.fields).map (field => [field.key, field.type]))
export const countsFieldLabel = (key: string): string => {
const labels: Record<string, string> = {
resource_id: 'columns.resourceId', version_no: 'columns.version',
user: 'history:actor', created_at: 'columns.changedAt', name: 'history:name',
title: 'columns.postTitle', url: 'columns.postURL', thumbnail_base: 'columns.thumbnailURL',
tags: 'columns.tags', tags_json: 'columns.tags', names: 'columns.names',
aliases: 'tags:aliases', category: 'common:categoryLabel',
language_code: 'columns.language', source_language_code: 'attributes.sourceLanguage',
changed_field: 'changedField' }
return labels[key] ?? `attributes.${key}`
}
export const countsEventTypes = ['create', 'update', 'discard', 'restore'] as const
export const countsAxisLabel = (
axis: CountsAxis,
definition: CountsDefinition,
locale: LocaleCode,
): { text: string; language: string } => {
const field = translateUIMessage (countsFieldLabel (axis.field), 'counts', locale)
if (definition.fields.find (item => item.key === axis.field)?.type !== 'datetime') return field
return translateUIMessage ('axisWithGrain', 'counts', locale, {
field: field.text,
grain: translateUIMessage (`grains.${axis.grain ?? 'month'}`, 'counts', locale).text })
}
export const countsAxisFields = (
definition: CountsDefinition,
history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest']
.map (key => ({ key, label: countsFieldLabel (key) }))