このコミットが含まれているのは:
2026-10-26 22:57:39 +09:00
コミット 5493673996
60個のファイルの変更、1202行の追加、273行の削除
+54 -24
ファイルの表示
@@ -1,6 +1,8 @@
import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
import { countsFieldLabel } from '@/lib/countsDefinitions'
import { apiDownload, apiGet } from '@/lib/api'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
@@ -58,6 +60,8 @@ export type CountsStateTotals = {
export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' }
export type CountsResult = {
populationCount?: number
populationCountScope?: string
rowHeaders?: CountsPivotHeader[]
columnHeaders?: CountsPivotHeader[]
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
@@ -95,7 +99,7 @@ export const countColumns: Record<string, string> = {
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
original_created_from: 'counts:columns.originalFrom',
original_created_before: 'counts:columns.originalBefore',
name: 'history:tagName', names: 'counts:columns.names',
name: 'counts:columns.name', names: 'counts:columns.names',
category: 'common:categoryLabel', aliases: 'tags:aliases',
deprecated: 'counts:columns.deprecated', user: 'history:actor',
created_at: 'counts:columns.changedAt' }
@@ -165,7 +169,9 @@ export const fieldsForCounts = (
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
: (countColumns[key] ?? countsFieldLabel (key)),
children: nested[key] }))
children: key === 'parents' && resource === 'tags'
? [...(nested.parents ?? []), { key: 'name', label: 'counts:columns.name' }]
: nested[key] }))
export const orderedCountFields = (
@@ -174,7 +180,7 @@ export const orderedCountFields = (
parent = '',
): CountField[] => {
const prefix = parent ? `${parent}.` : ''
const saved = (params.get ('order') ?? '').split (',')
const saved = (params.get ('order') ?? '').split (' ')
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
const ordered = keys.flatMap (key => {
const field = fields.find (field => `${prefix}${field.key}` === key)
@@ -201,8 +207,8 @@ export const reorderCountFields = (
const current = ordered.map (field => `${prefix}${field.key}`)
const next = [...current]
next.splice (last, 0, ...next.splice (first, 1))
return [...(params.get ('order') ?? '').split (',').filter (
key => key && !current.includes (key)), ...next].join (',')
return [...(params.get ('order') ?? '').split (' ').filter (
key => key && !current.includes (key)), ...next].join (' ')
}
@@ -220,7 +226,7 @@ export const selectedCountColumns = (
tag_descriptions: ['language_code', 'body', 'source_language_code'],
tag_groups: ['name', 'description', 'members'],
materials: ['url', 'source_kind', 'tag_id'] }
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (',')
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (' ')
: [...common, ...defaults[resource]]
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
@@ -234,13 +240,26 @@ export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
})
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
new Set (params.has ('children') ? params.get ('children')!.split (',')
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
export const selectedCountChildren = (params: URLSearchParams): Set<string> => {
const selected = new Set (params.has ('children') ? params.get ('children')!.split (' ')
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
const clean = (fields: CountField[], prefix: string) => {
for (const field of fields)
{
const path = `${prefix}.${field.key}`
if (field.children == null) continue
clean (field.children, path)
if (!field.children.some (child => selected.has (`${path}.${child.key}`)))
selected.delete (path)
}
}
for (const [path, fields] of Object.entries (nested)) clean (fields ?? [], path)
return selected
}
export const readCountSort = (value: string | null): CountSort[] =>
(value ?? '').split (',').flatMap (entry => {
(value ?? '').split (' ').flatMap (entry => {
const [key, direction] = entry.split (':')
return (key && (direction === 'asc' || direction === 'desc'))
? [{ key, direction }] : []
@@ -248,7 +267,7 @@ export const readCountSort = (value: string | null): CountSort[] =>
export const writeCountSort = (sort: CountSort[]): string =>
sort.map (item => `${item.key}:${item.direction}`).join (',')
sort.map (item => `${item.key}:${item.direction}`).join (' ')
export const changeCountSort = (
@@ -274,7 +293,7 @@ export const selectedCountFields = (
parent = '',
): CountField[] => {
const selected = params.has (selectionKey)
? new Set (params.get (selectionKey)!.split (',')) : null
? new Set (params.get (selectionKey)!.split (' ')) : null
return orderedCountFields (fields, params, parent)
.filter (field => selected == null || selected.has (field.key))
}
@@ -300,7 +319,7 @@ export const normaliseCountsParams = (
if (!params.has ('rows') && params.has ('axis'))
{
const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('axis')!
params.set ('rows', JSON.stringify ([{
params.set ('rows', encodeCountState ('rows', [{
field, ...(field === 'created_at' ? { grain: 'month' } : { }) }]))
}
if (params.has ('before') && !params.has ('through'))
@@ -309,16 +328,16 @@ export const normaliseCountsParams = (
params.delete (key)
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
{
const selected = new Set (params.get ('columns')!.split (','))
const selected = new Set (params.get ('columns')!.split (' '))
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
.join (','))
.join (' '))
}
if (params.has ('sort'))
params.set ('sort', params.get ('sort')!.split (',')
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (','))
params.set ('sort', params.get ('sort')!.split (' ')
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (' '))
if (params.has ('order'))
params.set ('order', params.get ('order')!.split (',')
.filter (key => key !== 'original_created_at').join (','))
params.set ('order', params.get ('order')!.split (' ')
.filter (key => key !== 'original_created_at').join (' '))
const defaults = definition?.defaults?.find (entry =>
entry.definition === params.get ('definition') && entry.mode === params.get ('mode'))
for (const { key, value } of defaults?.params ?? [])
@@ -327,13 +346,24 @@ export const normaliseCountsParams = (
if (key === 'sort')
{
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
const axes = readCountsJSON<CountsAxis[]> (params, 'rows', [])
const first = expected == null ? null : (JSON.parse (expected) as CountsAxis[])[0]
const axes = readCountState<CountsAxis[]> (params, 'rows', [])
const first = expected == null ? null : readCountState<CountsAxis[]> (new URLSearchParams ({ rows: expected }), 'rows', [])[0]
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
}
params.set (key, value)
}
const children = selectedCountChildren (params)
if (params.has ('children')) params.set ('children', [...children].filter (Boolean).join (' '))
if (params.has ('columns'))
{
const fields = fieldsForCounts (resource, definition)
params.set ('columns', params.get ('columns')!.split (' ').filter (key => {
const field = fields.find (field => field.key === key)
return field != null && (field.children == null
|| field.children.some (child => children.has (`${key}.${child.key}`)))
}).join (' '))
}
return params
}
@@ -393,13 +423,13 @@ export const countsExportPayload = (
{
const selected = selectedCountChildren (params)
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
.filter (path => selected.has (path)).join (','))
.filter (path => selected.has (path)).join (' '))
}
payload.delete ('current')
payload.delete ('row_numbers')
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
return { ...Object.fromEntries (payload), resource, format,
columns: columns.join (','), headers: JSON.stringify (headers),
columns: columns.join (' '), headers: JSON.stringify (headers),
labels: JSON.stringify (labels) }
}
-12
ファイルの表示
@@ -75,15 +75,3 @@ export const countsAxisFields = (
history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest']
.map (key => ({ key, label: countsFieldLabel (key) }))
export const readCountsJSON = <T,> (params: URLSearchParams, key: string, fallback: T): T => {
try
{
return JSON.parse (params.get (key) ?? JSON.stringify (fallback)) as T
}
catch
{
return fallback
}
}
+28 -3
ファイルの表示
@@ -1,14 +1,14 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes } from '@/lib/countsDefinitions'
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
import { translateUIMessage } from '@/lib/i18n'
import { localisedDateString, msToTime } from '@/lib/utils'
import type { CountColumn, CountField, CountRecord,
import type { CountColumn, CountField, CountRecord, CountsResult,
CountsResource, CountsVersion } from '@/lib/counts'
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
from '@/lib/countsClipboard'
import type { CountsDefinition } from '@/lib/countsDefinitions'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
@@ -308,6 +308,7 @@ export const countsExportLabels = (
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, {
@@ -384,3 +385,27 @@ export const countsDetailClipboardTable = (
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)
}
+5 -4
ファイルの表示
@@ -1,4 +1,5 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
@@ -17,7 +18,7 @@ export const fetchPosts = async (
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && { tag_refs: tagReferences }),
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -45,14 +46,14 @@ export const switchPostFilterLocale = async (
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
tag_refs: query.get ('tag_refs') ?? undefined } })
...queryRecordParams (query, 'tag_refs') } })
query.set ('tags', references.map (reference => reference.name).join (' '))
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
setQueryRecords (query, 'tag_refs', references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) }))))
...(reference.missing && { missing: true }) })))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
+3 -2
ファイルの表示
@@ -1,3 +1,4 @@
import { queryRecordParams } from '@/lib/queryState'
import { QueryClient } from '@tanstack/react-query'
import { match } from 'path-to-regexp'
@@ -175,8 +176,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.get ('tag_refs') && {
tagReferences: url.searchParams.get ('tag_refs')! }),
...(url.searchParams.has ('tag_refs') && {
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
+105
ファイルの表示
@@ -0,0 +1,105 @@
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
const schemas: Record<string, string[]> = {
conditions: ['field', 'operator', 'value', 'end', 'blank'],
drill: ['field', 'grain', 'value', 'value_type'],
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
column_order: ['value', 'value_type'],
measures: ['key', 'axis', 'value', 'value_type'] }
export const queryList = (value: string | null): string[] =>
value == null || value === '' ? [] : value.split (' ')
export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[] => {
const indexed = new Map<number, QueryRecord> ()
for (const [name, value] of params)
{
if (!name.startsWith (`${key}.`)) continue
const parts = name.slice (key.length + 1).split ('.')
if (parts.length !== 2 || !/^\d{1,2}$/.test (parts[0])
|| !schemas[key]?.includes (parts[1])) continue
const index = Number (parts[0])
const record = indexed.get (index) ?? { }
const field = parts[1]
record[field] = ['blank', 'negative', 'missing'].includes (field)
? value === '1' : (['tag_id', 'external_tag_id', 'axis'].includes (field)
? Number (value) : value)
indexed.set (index, record)
}
return [...indexed].sort ((a, b) => a[0] - b[0]).map (([, record]) => {
if (record.value_type === 'null') record.value = null
if (record.value_type === 'number') record.value = Number (record.value)
if (record.value_type === 'boolean') record.value = record.value === 'true'
delete record.value_type
return record
})
}
export const setQueryRecords = (
params: URLSearchParams,
key: string,
records: QueryRecord[],
): void => {
for (const name of [...params.keys ()])
if (name === key || name.startsWith (`${key}.`)) params.delete (name)
params.set (key, '')
records.forEach ((record, index) => {
for (const [field, value] of Object.entries (record))
{
if (value == null && field !== 'value') continue
if (!schemas[key]?.includes (field)) continue
const name = `${key}.${index}.${field}`
if (field === 'value' && ['drill', 'measures', 'column_order'].includes (key))
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
if (value == null) continue
params.set (name, typeof value === 'boolean'
? (['blank', 'negative', 'missing'].includes (field) ? (value ? '1' : '0') : String (value))
: String (value))
}
})
}
export const readCountState = <T,> (
params: URLSearchParams,
key: string,
fallback: T,
): T => {
if (!params.has (key)) return fallback
if (key === 'rows' || key === 'cols')
return queryList (params.get (key)).map (token => {
const [field, grain] = token.split (':')
return grain == null ? { field } : { field, grain }
}) as T
return queryRecords (params, key) as T
}
export const encodeCountState = (key: string, records: QueryRecord[]): string => {
if (key === 'rows' || key === 'cols')
return records.map (record => record.grain == null
? String (record.field) : `${record.field}:${record.grain}`).join (' ')
const params = new URLSearchParams ()
setQueryRecords (params, key, records)
return params.toString ()
}
export const setCountState = (params: URLSearchParams, key: string, value: string): void => {
if (schemas[key] != null)
{
const fields = new URLSearchParams (value)
setQueryRecords (params, key, queryRecords (fields, key))
}
else params.set (key, value)
}
export const queryRecordParams = (
params: URLSearchParams,
key: string,
): Record<string, string> => Object.fromEntries ([...params].filter (([name]) =>
name === key || name.startsWith (`${key}.`)))