515 行
21 KiB
TypeScript
515 行
21 KiB
TypeScript
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<string, Intl.DisplayNames> ()
|
|
|
|
|
|
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<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, 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, Record<string, string | null>>,
|
|
): 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<string, string | number>) =>
|
|
translateUIMessage (key, 'counts', locale, values).text
|
|
if (value === '__counts_private__') return '********'
|
|
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)
|
|
}
|