import { fieldsForCounts, orderedCountFields, selectedCountChildren, sortedCountChildren } from '@/lib/counts' import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions' import { languageUIMessage, 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 countsFieldValue = (row: CountRecord, field: string): unknown => row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())] 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 'latest': return row.latest ? 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: { if (column.startsWith ('aliases_')) return countsLogicalName (row, column, locale).text 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 = countsFieldValue (row, field) if (value == null) return { text: '' } if (field.startsWith ('aliases_') && Array.isArray (value)) return { text: value.map (entry => typeof entry === 'string' ? entry : String (entry?.name ?? '')).join ('\n') } 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') return languageUIMessage (code, locale) const master = row.scriptName if (typeof master === 'string' && master.trim () !== '' && master.toLowerCase () !== code.toLowerCase ()) return { text: master } try { const type = '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, countsFieldValue) 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 => [ field.key, field.children ? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children, `${path}.${field.key}`, params, locale) : countsFieldValue (row, field.key)]))) } // 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 const label = translateUIMessage (field.label, 'counts', locale, field.labelValues).text return `${label}: ${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, field.labelValues).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 new Set (['ja', 'ain', 'en', ...(definition?.nameColumns ?? []) .map (field => field.nameLocale?.languageCode).filter (code => code != null)])) labels[`language:${key}`] = languageUIMessage (key, 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, field.labelValues).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, field.labelValues).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] }) } } const zonedCalendarDate = ( year: number, month: number, day: number, hour: number, timezone: string, ): Date => { const target = Date.UTC (year, month - 1, day, hour) const formatter = new Intl.DateTimeFormat ('en-US-u-ca-gregory-nu-latn', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) const align = (date: Date) => { const parts = formatter.formatToParts (date) const part = (type: string) => Number ( parts.find (entry => entry.type === type)?.value ?? 0) const actual = Date.UTC (part ('year'), part ('month') - 1, part ('day'), part ('hour'), part ('minute'), part ('second')) return new Date (date.getTime () + target - actual) } return align (align (new Date (target))) } const countsDateValue = ( value: string, grain: string, locale: LocaleCode, timezone: string, ): string | null => { const patterns = { year: /^(\d{4})$/, quarter: /^(\d{4})-Q([1-4])$/, month: /^(\d{4})-(\d{2})$/, week: /^(\d{4})-W(\d{2})$/, day: /^(\d{4})-(\d{2})-(\d{2})$/, hour: /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):00$/ } const pattern = patterns[grain as keyof typeof patterns] const match = pattern == null ? null : value.match (pattern) if (match == null) return null const year = Number (match[1]) const weekStart = grain === 'week' ? new Date (Date.UTC (year, 0, 4) - ((new Date (Date.UTC (year, 0, 4)).getUTCDay () + 6) % 7) * 86_400_000 + (Number (match[2]) - 1) * 7 * 86_400_000) : null const calendarYear = weekStart?.getUTCFullYear () ?? year const month = weekStart == null ? (grain === 'quarter' ? (Number (match[2]) - 1) * 3 + 1 : (grain === 'month' || grain === 'day' || grain === 'hour' ? Number (match[2]) : 1)) : weekStart.getUTCMonth () + 1 const day = weekStart?.getUTCDate () ?? (grain === 'day' || grain === 'hour' ? Number (match[3]) : 1) const hour = grain === 'hour' ? Number (match[4]) : 12 const date = zonedCalendarDate (calendarYear, month, day, hour, timezone) const unknown = ({ year: 'month', quarter: 'day', month: 'day', week: 'hour', day: 'hour', hour: 'minute' } as const)[grain as keyof typeof patterns] const formatted = localisedDateString (date, locale, unknown, timezone) return grain === 'quarter' ? `${formatted} Q${match[2]}` : (grain === 'week' ? `${formatted} W${match[2]}` : formatted) } export const countsConditionValue = ( value: string | number | boolean | null | undefined, axis: CountsAxis, locale: LocaleCode, timezone: string, labels?: Record>, ): string => { if (axis.field === 'user' && value == null) return translateUIMessage ('bot', 'counts', locale).text if (value == null) return 'NULL' if (axis.field === 'tags' || axis.field === 'user') { if (labels == null) return '…' const names = labels[axis.field] if (names == null || !Object.prototype.hasOwnProperty.call (names, String (value))) return '—' return names[String (value)] || translateUIMessage ('notRegistered', 'counts', locale).text } if (axis.field === 'changed_field') return translateUIMessage (countsFieldLabel (String (value)), 'counts', locale).text if (typeof value === 'boolean') return translateUIMessage (`truthValues.${value}`, 'counts', locale).text if (axis.grain != null) return countsDateValue (String (value), axis.grain, locale, timezone) ?? String (value) if (axis.field === 'event_type') return translateUIMessage (`events.${value}`, 'counts', locale).text if (axis.field === 'category' || axis.field === 'tag_category') return translateUIMessage (`common:categories.${value}`, 'counts', locale).text if (['language_code', 'source_language_code'].includes (axis.field)) return languageUIMessage (String (value), locale).text return String (value) } export const countsSummaryValue = ( value: string | number | null, axis: CountsAxis, index: number, data: CountsResult, locale: LocaleCode, timezone = 'UTC', ): string => { const message = (key: string, values?: Record) => translateUIMessage (key, 'counts', locale, values).text if (value == null) return axis.field === 'user' ? message ('bot') : '' if (['language_code', 'source_language_code'].includes (axis.field)) return languageUIMessage (String (value), locale).text 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 (axis.field === 'tags') 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}`) const dateAxis = axis.field.endsWith ('_at') || axis.field.startsWith ('original_created_') const grain = axis.grain ?? (dateAxis ? 'month' : null) if (grain != null) { const date = countsDateValue (String (value), grain, locale, timezone) if (date != null) return date } return String (value) }