+ {history ?
+ update ('from', value ?? '')}/>
+ {label ('periodSeparator')}
+ update ('through', value ?? '')}/>
+
:
}
diff --git a/frontend/src/components/counts/CountsNestedTable.tsx b/frontend/src/components/counts/CountsNestedTable.tsx
index 3093b3c..8093050 100644
--- a/frontend/src/components/counts/CountsNestedTable.tsx
+++ b/frontend/src/components/counts/CountsNestedTable.tsx
@@ -4,7 +4,7 @@ import CountsSortHeader from '@/components/counts/CountsSortHeader'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import { changeCountSort, readCountSort, selectedCountChildren,
writeCountSort, orderedCountFields } from '@/lib/counts'
-import { countsLogicalName, countsNestedExport,
+import { countsLogicalName, countsNestedClipboardTable, countsNestedExport,
countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -67,6 +67,7 @@ const CountsNestedTable = (
className="relative isolate max-h-72 w-full min-w-0 overflow-auto">
field.key)}
identity={`${path}:${params}`} name={title}
+ copyTable={() => countsNestedClipboardTable (rows, fields, path, params, locale)}
className="text-xs">
diff --git a/frontend/src/components/counts/CountsResults.tsx b/frontend/src/components/counts/CountsResults.tsx
index ca94d72..7a7c298 100644
--- a/frontend/src/components/counts/CountsResults.tsx
+++ b/frontend/src/components/counts/CountsResults.tsx
@@ -8,8 +8,8 @@ import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
-import { countsCellCopy, countsCellText, countsImageURL, countsNestedRows }
- from '@/lib/countsDisplay'
+import { countsCellCopy, countsCellText, countsDetailClipboardTable,
+ countsImageURL, countsNestedRows } from '@/lib/countsDisplay'
import useDialogue from '@/lib/dialogues/useDialogue'
import { useLocale } from '@/lib/useLocale'
import { useCountsDefinition } from '@/lib/countsDefinitions'
@@ -101,6 +101,9 @@ const CountsResults = (
{tableColumns.length === 0 ? {label ('nested.noColumns')}
: (
countsDetailClipboardTable (data.versions ?? [],
+ columns.map (column => fields.find (field => field.key === column)!),
+ params, locale, rowNumbers ? (data.page - 1) * data.limit + 1 : null)}
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (',')}:${
(data.versions ?? []).map (row => row.id)}`}
className="text-left text-sm">
diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx
index 075043e..4ddb4a9 100644
--- a/frontend/src/components/counts/CountsSelectableTable.tsx
+++ b/frontend/src/components/counts/CountsSelectableTable.tsx
@@ -8,6 +8,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
import type { ClipboardEvent, ReactNode } from 'react'
+import type { CountsClipboardTable } from '@/lib/countsClipboard'
import type { CountsMenuAction } from '@/components/counts/CountsCellMenu'
type Point = { row: number; column: number }
@@ -20,13 +21,14 @@ const cellAt = (table: HTMLTableElement, point: Point) =>
const CountsSelectableTable = (
- { family, columns, identity, children, className, name }: {
+ { family, columns, identity, children, className, name, copyTable }: {
family: string
columns: string[]
identity: string
children: ReactNode
className?: string
- name?: { text: string; language: string } },
+ name?: { text: string; language: string }
+ copyTable?: () => CountsClipboardTable },
) => {
const id = useId ()
const root = useRef (null)
@@ -103,6 +105,14 @@ const CountsSelectableTable = (
}
const clipboard = (headers = false) => {
if (bounds == null || root.current == null) return null
+ if (copyTable != null)
+ {
+ const table = copyTable ()
+ return countsClipboard ({
+ columns: table.columns.slice (bounds.left, bounds.right + 1),
+ rows: table.rows.slice (bounds.top, bounds.bottom + 1)
+ .map (row => row.slice (bounds.left, bounds.right + 1)) }, headers)
+ }
const rows: string[][] = []
for (let row = bounds.top; row <= bounds.bottom; ++row)
{
diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts
index 562c294..5db1208 100644
--- a/frontend/src/lib/counts.ts
+++ b/frontend/src/lib/counts.ts
@@ -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 => {
diff --git a/frontend/src/lib/countsClipboard.ts b/frontend/src/lib/countsClipboard.ts
index 1715f88..070a8c9 100644
--- a/frontend/src/lib/countsClipboard.ts
+++ b/frontend/src/lib/countsClipboard.ts
@@ -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 =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 (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 = '' + safe.map ((row, index) => '' + 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:"\\@"'
diff --git a/frontend/src/lib/countsDisplay.ts b/frontend/src/lib/countsDisplay.ts
index 239491b..941b237 100644
--- a/frontend/src/lib/countsDisplay.ts
+++ b/frontend/src/lib/countsDisplay.ts
@@ -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]
+ }) }
+}
diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json
index 2e32ae3..f2541fe 100644
--- a/frontend/src/locales/ja/counts.json
+++ b/frontend/src/locales/ja/counts.json
@@ -35,8 +35,8 @@
"deprecated": "廃止状態",
"latest": "最新フラグ",
"thumbnailURL": "サムネール元 URL",
- "originalFrom": "オリジナルの投稿日時(開始境界)",
- "originalBefore": "オリジナルの投稿日時(終了境界・未満)",
+ "originalFrom": "オリジナルの投稿日時(以降)",
+ "originalBefore": "オリジナルの投稿日時(より前)",
"language": "言語",
"kind": "内部・外部",
"sections": "記載区間",
@@ -136,8 +136,8 @@
"history": "履歴",
"referenceAt": "基準日時",
"referenceHelp": "指定した日時における各データの最新版を集計・表示します。空欄の場合は現在の最新版を使用します。",
- "from": "開始(以上)",
- "through": "終了(以下)",
+ "from": "開始",
+ "through": "終了",
"axisItems": "項目一覧",
"rows": "行",
"cols": "列",
@@ -210,13 +210,14 @@
"parent_post_ids": "親投稿",
"parent_tag_ids": "上位タグ",
"video_ms": "動画時間",
- "original_created_from": "オリジナルの投稿日時(開始境界)",
- "original_created_before": "オリジナルの投稿日時(終了境界)",
+ "original_created_from": "オリジナルの投稿日時(以降)",
+ "original_created_before": "オリジナルの投稿日時(より前)",
"deprecated_at": "廃止日時",
"section_name": "節の名称",
"children": "子項目",
"profile": "出力設定",
"path": "出力パス"
},
- "rowNumbers": "行番号"
+ "rowNumbers": "行番号",
+ "periodSeparator": "〜"
}
diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx
index 4c0a927..6401734 100644
--- a/frontend/src/pages/CountsResourcePage.tsx
+++ b/frontend/src/pages/CountsResourcePage.tsx
@@ -14,7 +14,7 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
-import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
+import { countsRequestParams, defaultCountsMode, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { CountsDefinitionContext, countsAxisFields, countsResources, countsFieldLabel,
@@ -163,8 +163,9 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
{['latest', 'history'].map (definition => {
- const next = new URLSearchParams ({ definition, mode })
- for (const key of ['timezone', 'at', 'current'])
+ const next = new URLSearchParams ({
+ definition, mode: defaultCountsMode (definition) })
+ for (const key of ['timezone', 'at', 'current', 'row_numbers'])
if (params.get (key)) next.set (key, params.get (key)!)
const active = target === resource
&& params.get ('definition') === definition