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 () 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 => { const aliases: Record = { versionNo: 'version_no', languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' } const labels: Record = { } 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) => 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) }