ファイル
btrc-hub/frontend/src/lib/countsDisplay.ts
T
2026-10-26 22:57:39 +09:00

412 行
16 KiB
TypeScript

import { fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
import { translateUIMessage } from '@/lib/i18n'
import { localisedDateString, msToTime } from '@/lib/utils'
import type { CountColumn, CountField, CountRecord, CountsResult,
CountsResource, CountsVersion } from '@/lib/counts'
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
from '@/lib/countsClipboard'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
export const countsCellText = (
row: CountsVersion,
column: CountColumn,
locale: LocaleCode,
params = new URLSearchParams (),
): string => {
const timezone = params.get ('timezone') ?? 'UTC'
const date = (value: string) => localisedDateString (value, locale, null, timezone)
switch (column)
{
case 'resource_id': return String (row.resourceId)
case 'version_no': return String (row.versionNo)
case 'is_latest':
return row.isLatest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
case 'title': return row.title ?? ''
case 'url': return row.url ?? ''
case 'thumbnail':
case 'thumbnail_base': return row.thumbnailBase ?? ''
case 'name': return row.name ?? ''
case 'category':
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
case 'original_created_from':
case 'original_created_before':
{
const value = column === 'original_created_from'
? row.originalCreatedFrom : row.originalCreatedBefore
return value == null ? '' : date (value)
}
case 'deprecated':
return row.deprecated == null ? '' : date (row.deprecated)
case 'user':
if (row.createdByUser == null)
return translateUIMessage ('bot', 'counts', locale).text
return row.createdByUser.name
? translateUIMessage ('actorWithId', 'counts', locale, {
name: row.createdByUser.name, id: row.createdByUser.id }).text
: translateUIMessage ('history:anonymous', 'counts', locale, {
id: row.createdByUser.id }).text
case 'created_at': return date (row.createdAt)
default:
{
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
const value = row[key]
if (['discarded_at', 'updated_at'].includes (column) && typeof value === 'string')
return date (value)
if (column === 'event_type')
return translateUIMessage (`events.${value}`, 'counts', locale).text
if (column === 'language_code' || column === 'source_language_code')
return countsLogicalName ({ languageCode: value }, 'languageCode', locale).text
return value == null ? '' : (typeof value === 'object'
? JSON.stringify (value, null, 2) : String (value))
}
}
}
export const countsNestedRows = (row: CountsVersion, column: string): CountRecord[] => {
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
const value = row[key]
if (column === 'export_paths_json' && value != null && typeof value === 'object')
return Object.entries (value).map (([profile, path]) => ({ profile, path }))
return Array.isArray (value) ? value as CountRecord[] : []
}
export const countsCSVCell = (
row: CountsVersion,
column: CountColumn,
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): string => {
const field = fieldsForCounts (resource).find (field => field.key === column)!
if (field.children != null)
{
const rows = countsNestedRows (row, column)
return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
}
return countsCellText (row, column, locale, params)
}
const countsSectionRanges = (value: unknown, locale: LocaleCode): string[] | null => {
if (!Array.isArray (value)) return null
const time = (ms: number): string => {
const parts = msToTime (ms).split (':')
if (parts.length === 2) parts[0] = parts[0].padStart (2, '0')
return parts.join (':')
}
return value.map ((section: { beginMs: number; endMs: number | null }) =>
translateUIMessage ('sections.range', 'counts', locale, {
begin: time (section.beginMs),
end: section.endMs == null ? '' : time (section.endMs) }).text)
}
const displayNames = new Map<string, Intl.DisplayNames> ()
export const countsLogicalName = (
row: CountRecord,
field: string,
locale: LocaleCode,
): { text: string; language?: string } => {
const value = row[field]
if (value == null) return { text: '' }
if (field === 'sections')
return { text: (countsSectionRanges (value, locale) ?? []).join (
translateUIMessage ('sections.separator', 'counts', locale).text) }
const code = String (value)
if (field === 'languageCode' || field === 'scriptCode')
{
if (field === 'languageCode' && ['ja', 'ain'].includes (code))
return translateUIMessage (`history:languageNames.${code}`, 'counts', locale)
const master = row[field === 'languageCode' ? 'languageName' : 'scriptName']
if (typeof master === 'string' && master.trim () !== ''
&& master.toLowerCase () !== code.toLowerCase ())
return { text: master }
try
{
const type = field === 'languageCode' ? 'language' : 'script'
if (!displayNames.has (type))
displayNames.set (type, new Intl.DisplayNames (['ja'], { type, fallback: 'none' }))
const text = displayNames.get (type)!.of (code)
if (text != null && text !== code) return { text, language: 'ja' }
}
catch
{
// Invalid or unregistered codes have no logical label; never expose the code as a name.
}
return translateUIMessage ('notRegistered', 'counts', locale)
}
if (field === 'kind' || field === 'category')
return translateUIMessage (field === 'kind' ? `kinds.${code}`
: `common:categories.${code}`, 'counts', locale)
if (typeof value === 'boolean')
return translateUIMessage (`boolean.${value}`, 'counts', locale)
return { text: String (value) }
}
export const countsNestedOrder = (
rows: CountRecord[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountRecord[] => sortedCountChildren (rows, path, params, (row, key) =>
['languageCode', 'scriptCode'].includes (key)
? countsLogicalName (row, key, locale).text : row[key])
export const countsNestedExport = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): unknown => {
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0) return rows.map (() => ({ }))
return countsNestedOrder (rows, path, params, locale).map (row =>
Object.fromEntries (columns.map (field => [
translateUIMessage (field.label, 'counts', locale).text,
field.children
? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: (field.key === 'sections'
? countsSectionRanges (row[field.key], locale)
: countsLogicalName (row, field.key, locale).text)])))
}
// A chain with one selected scalar leaf is displayed and copied as one cell.
export const countsNestedIsScalar = (
fields: CountField[],
path: string,
params: URLSearchParams,
): boolean => {
const selected = selectedCountChildren (params)
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length !== 1) return false
const field = columns[0]
return field.children == null
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params)
}
export const countsNestedScalarText = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): string => {
const selected = selectedCountChildren (params)
const field = fields.find (field => selected.has (`${path}.${field.key}`))
if (field == null) return ''
return countsNestedOrder (rows, path, params, locale).map (row => field.children == null
? countsLogicalName (row, field.key, locale).text
: countsNestedScalarText (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)).join (' ')
}
export const countsNestedText = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): string => {
if (countsNestedIsScalar (fields, path, params))
return countsNestedScalarText (rows, fields, path, params, locale)
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0)
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
const value = field.children
? countsNestedText (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: countsLogicalName (row, field.key, locale).text
return `${translateUIMessage (field.label, 'counts', locale).text}: ${value}`
}).join (' | ')).join ('\n')
}
export const countsCellCopy = (
row: CountsVersion,
field: CountField,
params: URLSearchParams,
locale: LocaleCode,
): string => {
if (field.children == null) return countsCellText (row, field.key as CountColumn, locale, params)
const rows = countsNestedRows (row, field.key)
if (countsNestedIsScalar (field.children, field.key, params))
return countsNestedScalarText (rows, field.children, field.key, params, locale)
return params.get ('nested_json') === '1'
? JSON.stringify (
countsNestedExport (rows, field.children, field.key, params, locale))
: countsNestedText (rows, field.children, field.key, params, locale)
}
// Historical URLs are displayed without fetching current attachments or metadata.
export const countsImageURL = (value: string | null | undefined): string | null => {
if (!value) return null
try
{
const url = new URL (value)
return (['https:', 'http:'].includes (url.protocol) && !url.username && !url.password)
? url.href : null
}
catch
{
return null
}
}
export const countsExportLabels = (
resource: CountsResource,
locale: LocaleCode,
definition?: CountsDefinition,
): Record<string, string> => {
const aliases: Record<string, string> = { versionNo: 'version_no',
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
const labels: Record<string, string> = { }
const visit = (fields: CountField[]) => {
for (const field of fields)
{
labels[aliases[field.key] ?? field.key] = translateUIMessage (
field.label, 'counts', locale).text
if (field.children) visit (field.children)
}
}
visit (fieldsForCounts (resource, definition))
for (const key of ['internal', 'external'])
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
labels[`category:${key}`] = translateUIMessage (
`common:categories.${key}`, 'counts', locale).text
for (const key of ['true', 'false'])
labels[`boolean:${key}`] = translateUIMessage (`boolean.${key}`, 'counts', locale).text
for (const key of ['ja', 'ain'])
labels[`language:${key}`] = translateUIMessage (
`history:languageNames.${key}`, 'counts', locale).text
labels.id = translateUIMessage ('columns.resourceId', 'counts', locale).text
labels.name = translateUIMessage ('history:name', 'counts', locale).text
for (const event of countsEventTypes)
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
labels.subtotal = translateUIMessage ('subtotal', 'counts', locale).text
labels.singleTotal = translateUIMessage ('singleTotal', 'counts', locale).text
labels.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text
labels.bot = translateUIMessage ('bot', 'counts', locale).text
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
name: '{{name}}', id: '{{id}}' }).text
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
return labels
}
const clipboardColumns = (
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountsClipboardColumn[] => {
const selected = selectedCountChildren (params)
return orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
.map (field => ({ label: translateUIMessage (field.label, 'counts', locale).text,
children: (field.children == null
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params))
? undefined : clipboardColumns (
field.children, `${path}.${field.key}`, params, locale) }))
}
export const countsNestedClipboardTable = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountsClipboardTable => {
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
return {
columns: clipboardColumns (fields, path, params, locale),
rows: countsNestedOrder (rows, path, params, locale).map (row => columns.map (field =>
field.children == null ? countsLogicalName (row, field.key, locale).text
: (countsNestedIsScalar (field.children, `${path}.${field.key}`, params)
? countsNestedScalarText (row[field.key] as CountRecord[] ?? [],
field.children, `${path}.${field.key}`, params, locale)
: countsNestedClipboardTable (row[field.key] as CountRecord[] ?? [],
field.children, `${path}.${field.key}`, params, locale)))) }
}
export const countsDetailClipboardTable = (
rows: CountsVersion[],
fields: CountField[],
params: URLSearchParams,
locale: LocaleCode,
rowStart: number | null = null,
): CountsClipboardTable => {
const columns = fields.map (field => ({
label: translateUIMessage (field.label, 'counts', locale).text,
children: (field.children == null
|| countsNestedIsScalar (field.children, field.key, params))
? undefined : clipboardColumns (
field.children, field.key, params, locale) }))
if (rowStart != null)
columns.unshift ({ label: translateUIMessage ('rowNumberHeading', 'counts', locale).text,
children: undefined })
return { columns, rows: rows.map ((row, index) => {
const cells: CountsClipboardCell[] = fields.map (field => field.children == null
? countsCellText (row, field.key, locale, params)
: (countsNestedIsScalar (field.children, field.key, params)
? countsNestedScalarText (countsNestedRows (row, field.key), field.children,
field.key, params, locale)
: countsNestedClipboardTable (countsNestedRows (row, field.key), field.children,
field.key, params, locale)))
return rowStart == null ? cells : [String (rowStart + index), ...cells]
}) }
}
export const countsSummaryValue = (
value: string | number | null,
axis: CountsAxis,
index: number,
data: CountsResult,
locale: LocaleCode,
): string => {
const message = (key: string, values?: Record<string, string | number>) =>
translateUIMessage (key, 'counts', locale, values).text
if (value == null) return axis.field === 'user' ? message ('bot') : ''
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
if (axis.field === 'user') return message ('actorWithId', {
name: logical || message ('notRegistered'), id: value })
if (logical != null) return logical
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
return message ('notRegistered')
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value)))
if (axis.field === 'event_type') return message (`events.${value}`)
if (axis.field === 'category' || axis.field === 'tag_category')
return message (`common:categories.${value}`)
return String (value)
}