このコミットが含まれているのは:
2026-10-25 16:00:37 +09:00
コミット 3e05757820
19個のファイルの変更、1692行の追加、388行の削除
+198 -22
ファイルの表示
@@ -1,34 +1,160 @@
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { PostVersion, TagVersion } from '@/types'
import type { TagNameSnapshot } from '@/types'
export type CountsResource = 'posts' | 'tags'
export type CountsName = TagNameSnapshot & {
languageCode: string; languageName?: string | null; scriptName?: string | null }
export type CountsTag = {
id: number
kind: 'internal' | 'external'
tagId: number | null
externalTagId: number | null
versionNo: number | null
name: string | null
category: string | null
sections: { beginMs: number; endMs: number | null }[]
names: CountsName[] }
export type CountsVersion = {
id: number
resourceId: number
versionNo: number
isLatest: boolean
createdAt: string
createdByUser: { id: number; name: string | null } | null
title?: string | null
url?: string
thumbnailBase?: string | null
tags?: CountsTag[]
parents: { id: number }[]
videoMs?: number | null
originalCreatedFrom?: string | null
originalCreatedBefore?: string | null
name?: string | null
names?: CountsName[]
category?: string
aliases?: { languageCode: string; name: string; scriptCode: string }[]
deprecated?: string | null }
export type CountsResult = {
count: number
page: number
limit: number
groupCount?: number
groups?: { key: string | number | null; name: string | null; count: number }[]
versions?: (PostVersion | TagVersion)[] }
versions?: CountsVersion[] }
export type CountSort = { key: string; direction: 'asc' | 'desc' }
export type CountField = { key: string; label: string; children?: CountField[] }
export type CountRecord = Record<string, unknown>
export const countColumns = {
created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId',
version_no: 'history:version', user: 'history:actor',
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
version_no: 'history:version', is_latest: 'counts:columns.latest',
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags',
removed_tags: 'counts:columns.removedTags', name: 'history:tagName',
category: 'common:categoryLabel', aliases: 'tags:aliases', parents: 'tags:parents',
deprecated: 'counts:columns.deprecated' }
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
original_created_at: 'shared:labels.originalCreatedAt',
original_created_from: 'counts:columns.originalFrom',
original_created_before: 'counts:columns.originalBefore',
name: 'history:tagName', names: 'counts:columns.names',
category: 'common:categoryLabel', aliases: 'tags:aliases',
deprecated: 'counts:columns.deprecated', user: 'history:actor',
created_at: 'counts:columns.changedAt' }
export type CountColumn = keyof typeof countColumns
const aliasFields: CountField[] = [
{ key: 'name', label: 'history:name' },
{ key: 'scriptCode', label: 'history:nameChanges.script' }]
const nameFields: CountField[] = [
{ key: 'languageCode', label: 'counts:columns.language' },
{ key: 'scriptCode', label: 'history:nameChanges.script' },
{ key: 'name', label: 'history:name' },
{ key: 'aliases', label: 'tags:aliases', children: aliasFields },
{ key: 'autoGenerated', label: 'history:nameChanges.automatic' }]
const nested: Partial<Record<CountColumn, CountField[]>> = {
tags: [
{ key: 'id', label: 'counts:columns.resourceId' },
{ key: 'versionNo', label: 'history:version' },
{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'name', label: 'history:name' },
{ key: 'category', label: 'common:categoryLabel' },
{ key: 'sections', label: 'counts:columns.sections', children: [
{ key: 'beginMs', label: 'counts:columns.begin' },
{ key: 'endMs', label: 'counts:columns.end' }] },
{ key: 'names', label: 'counts:columns.names', children: nameFields }],
names: nameFields,
aliases: [{ key: 'languageCode', label: 'counts:columns.language' }, ...aliasFields],
parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
'created_at', 'resource_id', 'version_no', 'user',
...(resource === 'posts' ? ['thumbnail'] as const : []),
'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const
: ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)]
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'original_created_at', 'original_created_from', 'original_created_before'] as const
: ['name', 'names', 'category', 'aliases', 'parents', 'deprecated'] as const),
'user', 'created_at']
export const fieldsForCounts = (resource: CountsResource): CountField[] =>
columnsForCounts (resource).map (key => ({
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents' : countColumns[key],
children: nested[key] }))
export const selectedCountColumns = (
resource: CountsResource,
params: URLSearchParams,
): CountColumn[] => {
const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
columnsForCounts (resource).filter (key => ![
'thumbnail', 'original_created_from', 'original_created_before']
.includes (key))
return columnsForCounts (resource).filter (key => selected.includes (key))
}
export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
fields.flatMap (field => {
const path = prefix ? `${prefix}.${field.key}` : field.key
return [path, ...countChildPaths (field.children ?? [], path)]
})
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
new Set (params.has ('children') ? params.get ('children')!.split (',') :
Object.entries (nested).flatMap (([key, fields]) =>
countChildPaths (fields, key)))
export const readCountSort = (value: string | null): CountSort[] =>
(value ?? '').split (',').flatMap (entry => {
const [key, direction] = entry.split (':')
return (key && (direction === 'asc' || direction === 'desc'))
? [{ key, direction }] : []
})
export const writeCountSort = (sort: CountSort[]): string =>
sort.map (item => `${item.key}:${item.direction}`).join (',')
export const changeCountSort = (
sort: CountSort[],
key: string,
additive: boolean,
): CountSort[] => {
const previous = sort.find (item => item.key === key)
if (!additive)
return [{ key, direction: previous?.direction === 'asc' ? 'desc' : 'asc' }]
if (previous == null)
return [...sort, { key, direction: 'asc' }]
return previous.direction === 'desc'
? sort.filter (item => item.key !== key)
: sort.map (item => item.key === key ? { key, direction: 'desc' } : item)
}
export const normaliseCountsParams = (
@@ -40,25 +166,75 @@ export const normaliseCountsParams = (
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
params.set ('axis', 'month')
if (params.has ('columns'))
{
const available = columnsForCounts (resource)
const columns = params.get ('columns')!.split (',')
.filter (column => available.includes (column as CountColumn))
if (columns.length > 0)
params.set ('columns', [...new Set (columns)].join (','))
else
params.delete ('columns')
}
params.set ('columns', selectedCountColumns (resource, params).join (','))
return params
}
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params)
for (const key of ['columns', 'children', 'child_sort'])
result.delete (key)
return result
}
export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
locale, params: Object.fromEntries (params) })
locale, params: Object.fromEntries (countsRequestParams (params)) })
const stableValue = (value: unknown): unknown => {
if (Array.isArray (value)) return value.map (stableValue)
if (value != null && typeof value === 'object')
{
return Object.fromEntries (Object.entries (value)
.sort (([a], [b]) => a < b ? -1 : (a > b ? 1 : 0))
.map (([key, entry]) => [key, stableValue (entry)]))
}
return value
}
const compareValue = (first: unknown, second: unknown): number => {
if (Array.isArray (first) && Array.isArray (second) && first.length !== second.length)
return first.length - second.length
if (typeof first === 'number' && typeof second === 'number') return first - second
if (typeof first === 'boolean' && typeof second === 'boolean')
return Number (first) - Number (second)
const left = typeof first === 'object' ? JSON.stringify (stableValue (first)) : String (first)
const right = typeof second === 'object'
? JSON.stringify (stableValue (second)) : String (second)
return left < right ? -1 : (left > right ? 1 : 0)
}
export const sortedCountChildren = (
rows: CountRecord[],
path: string,
params: URLSearchParams,
valueFor: (row: CountRecord, key: string) => unknown = (row, key) => row[key],
): CountRecord[] => {
const sort = readCountSort (params.get ('child_sort'))
.filter (item => item.key.startsWith (`${path}/`))
return rows.map ((row, index) => ({ row, index })).sort ((first, second) => {
for (const item of sort)
{
const key = item.key.slice (path.length + 1)
const left = valueFor (first.row, key)
const right = valueFor (second.row, key)
if (left == null && right != null) return 1
if (left != null && right == null) return -1
if (left == null && right == null) continue
const compared = compareValue (left, right)
if (compared !== 0) return item.direction === 'asc' ? compared : -compared
}
return first.index - second.index
}).map (item => item.row)
}
export const countsCSV = (rows: string[][]): string =>