このコミットが含まれているのは:
2026-10-26 18:59:25 +09:00
コミット 88711d2bd8
10個のファイルの変更、183行の追加、39行の削除
+7 -3
ファイルの表示
@@ -269,6 +269,10 @@ export const selectedCountFields = (
}
export const defaultCountsMode = (definition: string | null): 'summary' | 'details' =>
definition === 'history' ? 'details' : 'summary'
export const normaliseCountsParams = (
resource: CountsResource,
source: URLSearchParams,
@@ -276,10 +280,11 @@ export const normaliseCountsParams = (
const params = new URLSearchParams (source)
if (!params.has ('definition'))
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
if (!params.has ('current')) params.set ('current', params.get ('at') ? '0' : '1')
const legacy = params.get ('mode')
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
if (!params.has ('mode')) params.set ('mode', 'summary')
if (!params.has ('mode')) params.set ('mode', defaultCountsMode (params.get ('definition')))
if (!params.has ('rows') && params.has ('axis'))
{
const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('axis')!
@@ -305,8 +310,7 @@ export const isCountsState = (params: URLSearchParams): boolean =>
export const countsUseLatest = (params: URLSearchParams): boolean =>
params.get ('current') === '1'
|| (params.get ('current') !== '0' && !params.get ('at'))
params.get ('current') !== '0'
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
+58 -2
ファイルの表示
@@ -1,11 +1,67 @@
export type CountsClipboard = { text: string; html: string }
export type CountsClipboardColumn = { label: string; children?: CountsClipboardColumn[] }
export type CountsClipboardCell = string | CountsClipboardTable
export type CountsClipboardTable = {
columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][] }
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
export const countsClipboard = (rows: string[][], headers = false): CountsClipboard => {
const columnWidth = (column: CountsClipboardColumn): number =>
column.children?.reduce ((sum, child) => sum + columnWidth (child), 0) || 1
const headerDepth = (columns: CountsClipboardColumn[]): number =>
columns.length === 0 ? 0 : 1 + Math.max (0, ...columns.map (column =>
headerDepth (column.children ?? [])))
const headerRows = (columns: CountsClipboardColumn[]): string[][] => {
const width = columns.reduce ((sum, column) => sum + columnWidth (column), 0)
const rows = Array.from ({ length: headerDepth (columns) },
() => Array<string> (width).fill (''))
const place = (fields: CountsClipboardColumn[], row: number, offset: number) => {
for (const field of fields)
{
rows[row][offset] = field.label
if (field.children?.length) place (field.children, row + 1, offset)
offset += columnWidth (field)
}
}
if (columns.length) place (columns, 0, 0)
return rows
}
const dataRows = (
table: CountsClipboardTable,
columns = table.columns,
): string[][] => {
if (columns.length === 0) return []
return table.rows.flatMap (record => {
const blocks = columns.map ((column, index) => {
const value = record[index] ?? ''
return typeof value === 'string' ? [[value]]
: dataRows (value, column.children ?? value.columns)
})
const height = Math.max (1, ...blocks.map (block => block.length))
// Sibling arrays share the parent row's height; they never form a Cartesian product.
return Array.from ({ length: height }, (_, row) => columns.flatMap ((column, index) =>
Array.from ({ length: columnWidth (column) }, (_, offset) =>
blocks[index][row]?.[offset] ?? '')))
})
}
export const countsClipboard = (
source: string[][] | CountsClipboardTable,
headers = false,
): CountsClipboard => {
const headings = !Array.isArray (source) && headers ? headerRows (source.columns) : []
const rows = Array.isArray (source) ? source : [...headings, ...dataRows (source)]
const headingCount = Array.isArray (source) ? (headers ? 1 : 0) : headings.length
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
// Prefix formula-like text in both representations, including leading whitespace.
const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value)
@@ -13,7 +69,7 @@ export const countsClipboard = (rows: string[][], headers = false): CountsClipbo
const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value)
? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n')
const html = '<html><body><table>' + safe.map ((row, index) => '<tr>' + row.map (value => {
const tag = headers && index === 0 ? 'th' : 'td'
const tag = index < headingCount ? 'th' : 'td'
// Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae.
const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value)
const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"'
+60
ファイルの表示
@@ -5,6 +5,8 @@ import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/ut
import type { CountColumn, CountField, CountRecord,
CountsResource, CountsVersion } from '@/lib/counts'
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
from '@/lib/countsClipboard'
import type { CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
@@ -278,3 +280,61 @@ export const countsExportLabels = (
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).text,
children: field.children == null ? 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
: 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).text,
children: field.children == null ? undefined : clipboardColumns (
field.children, field.key, params, locale) }))
if (rowStart != null)
columns.unshift ({ label: translateUIMessage ('rowNumbers', '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)
: countsNestedClipboardTable (countsNestedRows (row, field.key), field.children,
field.key, params, locale))
return rowStart == null ? cells : [String (rowStart + index), ...cells]
}) }
}