このコミットが含まれているのは:
@@ -58,7 +58,8 @@ export type CountsStateTotals = {
|
||||
deprecatedTags: number | null
|
||||
externalAssignments: number | null }
|
||||
export type CountsPivotHeader = { values: (string | number | null)[]
|
||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||
kind: 'data' | 'subtotal' | 'grand_total'
|
||||
depth?: number; axisCount?: number; labelKey?: string | null }
|
||||
export type CountsResult = {
|
||||
populationCount?: number
|
||||
excludedCount?: number | null
|
||||
@@ -70,7 +71,7 @@ export type CountsResult = {
|
||||
rowHeaders?: CountsPivotHeader[]
|
||||
columnHeaders?: CountsPivotHeader[]
|
||||
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
|
||||
count: number }[]
|
||||
count: number; rowDepth?: number; columnDepth?: number; labelKey?: string | null }[]
|
||||
executionAt?: string
|
||||
columnValues?: (string | number | null)[][]
|
||||
cells?: { values: (string | number | null)[]; count: number }[]
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { CountField, CountsResource } from '@/lib/counts'
|
||||
|
||||
export type CountsAxis = { field: string; grain?: string }
|
||||
export type CountsCondition = {
|
||||
field: string; operator: string; value?: string; end?: string; blank?: boolean }
|
||||
field: string; operator: string; value?: string; end?: string; blank?: boolean
|
||||
input?: 'single' | 'multiple' | 'list'; values?: (string | number | boolean)[] }
|
||||
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
||||
| 'json' | 'boolean' | 'interval'
|
||||
|
||||
@@ -22,7 +23,9 @@ export const visibleCountsResources = [
|
||||
|
||||
export type CountsFieldMetadata = {
|
||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
||||
labels?: string[]; multiple?: boolean }
|
||||
labels?: string[]; multiple?: boolean; candidateSource?: 'categories' | 'tags'
|
||||
setType?: 'tag_reference'; inputTypes?: string[]
|
||||
choices?: { value: string; label: string }[] }
|
||||
export type CountsDefinition = {
|
||||
fields: CountsFieldMetadata[]
|
||||
outputFields?: CountField[]
|
||||
@@ -44,6 +47,31 @@ export const useCountsDefinition = (): CountsDefinition => {
|
||||
export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsDefinition> =>
|
||||
apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
|
||||
|
||||
export type CountsCandidate = {
|
||||
value: string; id?: number; name: string | null; category?: string | null
|
||||
kind?: 'internal' | 'external' }
|
||||
export type CountsCandidatesResult = {
|
||||
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
||||
|
||||
export const fetchCountsCandidates = (
|
||||
params: URLSearchParams,
|
||||
locale: LocaleCode,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CountsCandidatesResult> => apiGet (
|
||||
'/counts/definitions/posts/candidates', {
|
||||
params: Object.fromEntries (params), locale, signal })
|
||||
|
||||
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
|
||||
|
||||
export const countsConditionInputs = (
|
||||
field: CountsFieldMetadata | undefined,
|
||||
operator: string,
|
||||
): ('single' | 'multiple' | 'list')[] => {
|
||||
if (!countsSetOperators.includes (operator)) return ['single']
|
||||
if (field?.candidateSource === 'tags') return ['list']
|
||||
return field?.candidateSource == null ? ['multiple'] : ['list', 'multiple']
|
||||
}
|
||||
|
||||
|
||||
export const countsFilterFields = (
|
||||
definition: CountsDefinition,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { match } from 'path-to-regexp'
|
||||
|
||||
import { getHelpArticle,
|
||||
@@ -11,12 +9,16 @@ import { getHelpArticle,
|
||||
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
|
||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { queryRecordParams } from '@/lib/queryState'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||
import { fetchWikiAutolinkCandidates,
|
||||
fetchWikiPageByReference,
|
||||
fetchWikiTag,
|
||||
fetchWikiPages } from '@/lib/wiki'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
|
||||
|
||||
@@ -326,6 +328,7 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
|
||||
|
||||
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
|
||||
const u = new URL (urlLike, location.origin)
|
||||
if (isCountsPath (u.pathname)) return
|
||||
if (u.origin !== location.origin)
|
||||
return
|
||||
|
||||
|
||||
@@ -173,5 +173,6 @@ export const theatresKeys = {
|
||||
|
||||
export const countsKeys = {
|
||||
definition: (resource: string) => ['counts', 'definition', resource] as const,
|
||||
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
|
||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||
['counts', resource, locale, params] as const }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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'],
|
||||
conditions: ['field', 'operator', 'value', 'end', 'blank', 'input', 'values'],
|
||||
condition_values: ['condition', 'value', 'value_type'],
|
||||
drill: ['field', 'grain', 'value', 'value_type'],
|
||||
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
|
||||
column_order: ['value', 'value_type'],
|
||||
@@ -23,8 +24,8 @@ export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[
|
||||
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)
|
||||
record[field] = ['blank', 'negative', 'missing', 'values'].includes (field)
|
||||
? value === '1' : (['tag_id', 'external_tag_id', 'axis', 'condition'].includes (field)
|
||||
? Number (value) : value)
|
||||
indexed.set (index, record)
|
||||
}
|
||||
@@ -52,11 +53,13 @@ export const setQueryRecords = (
|
||||
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))
|
||||
if (field === 'value'
|
||||
&& ['drill', 'measures', 'column_order', 'condition_values'].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))
|
||||
? (['blank', 'negative', 'missing', 'values'].includes (field)
|
||||
? (value ? '1' : '0') : String (value))
|
||||
: String (value))
|
||||
}
|
||||
})
|
||||
@@ -74,16 +77,34 @@ export const readCountState = <T,> (
|
||||
const [field, grain] = token.split (':')
|
||||
return grain == null ? { field } : { field, grain }
|
||||
}) as T
|
||||
return queryRecords (params, key) as T
|
||||
const records = queryRecords (params, key)
|
||||
if (key === 'conditions')
|
||||
{
|
||||
const values = queryRecords (params, 'condition_values')
|
||||
return records.map ((record, index) => record.values === true
|
||||
? { ...record, values: values.filter (entry => entry.condition === index)
|
||||
.map (entry => entry.value) } : record) as T
|
||||
}
|
||||
return records as T
|
||||
}
|
||||
|
||||
|
||||
export const encodeCountState = (key: string, records: QueryRecord[]): string => {
|
||||
export const encodeCountState = (key: string, records: Record<string, unknown>[]): 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)
|
||||
const values: QueryRecord[] = []
|
||||
const flat = records.map ((record, condition) => {
|
||||
if (key !== 'conditions' || !Array.isArray (record.values)) return record as QueryRecord
|
||||
const { values: selected, ...fields } = record
|
||||
for (const value of selected as unknown[])
|
||||
values.push ({ condition, value: value as string | number | boolean })
|
||||
return { ...fields, values: true } as QueryRecord
|
||||
})
|
||||
setQueryRecords (params, key, flat)
|
||||
if (key === 'conditions' && flat.some (record => record.values === true))
|
||||
setQueryRecords (params, 'condition_values', values)
|
||||
return params.toString ()
|
||||
}
|
||||
|
||||
@@ -93,6 +114,14 @@ export const setCountState = (params: URLSearchParams, key: string, value: strin
|
||||
{
|
||||
const fields = new URLSearchParams (value)
|
||||
setQueryRecords (params, key, queryRecords (fields, key))
|
||||
if (key === 'conditions')
|
||||
{
|
||||
for (const name of [...params.keys ()])
|
||||
if (name === 'condition_values' || name.startsWith ('condition_values.'))
|
||||
params.delete (name)
|
||||
if (fields.has ('condition_values'))
|
||||
setQueryRecords (params, 'condition_values', queryRecords (fields, 'condition_values'))
|
||||
}
|
||||
}
|
||||
else params.set (key, value)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { unlocalisedPath } from '@/lib/locales'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { IndexRouteObject, Location, NonIndexRouteObject, Params } from 'react-router-dom'
|
||||
|
||||
@@ -20,6 +22,7 @@ export type SitemapSource =
|
||||
| 'descriptions' | 'tag_groups' | 'theatres'
|
||||
|
||||
export type PageRouteHandle = {
|
||||
prefetch?: false
|
||||
preload?: (locale: LocaleCode) => Promise<unknown>
|
||||
prepare?: (request: PagePreparation) => Promise<number>
|
||||
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
|
||||
@@ -33,3 +36,9 @@ export type PageRouteHandle = {
|
||||
export type PageRoute = { handle?: PageRouteHandle } & (
|
||||
Omit<IndexRouteObject, 'handle'>
|
||||
| (Omit<NonIndexRouteObject, 'handle' | 'children'> & { children?: PageRoute[] }))
|
||||
|
||||
|
||||
export const isCountsPath = (pathname: string): boolean => {
|
||||
const path = unlocalisedPath (pathname).toLowerCase ()
|
||||
return path === '/counts' || path.startsWith ('/counts/')
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiGet, isApiError } from '@/lib/api'
|
||||
import { parseHelpEdition } from '@/lib/help'
|
||||
import { fetchLocales, isLocaleCode, localeLanguage } from '@/lib/locales'
|
||||
import { withRenderContext } from '@/lib/renderContext.server'
|
||||
import { isCountsPath } from '@/lib/routeLocales'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { PageRoute, SitemapSource } from '@/lib/routeLocales'
|
||||
@@ -27,6 +28,7 @@ const sitemapRoutes = (locale: LocaleCode): SitemapRoute[] => {
|
||||
|| (handle?.locale != null && handle.locale !== locale))
|
||||
return []
|
||||
const path = [parent, route.path].filter (Boolean).join ('/')
|
||||
if (isCountsPath (`/${path}`)) return []
|
||||
return [
|
||||
...(handle?.sitemap ?? []).map (source => ({ path, source })),
|
||||
...collect (route.children ?? [], path)]
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする