このコミットが含まれているのは:
@@ -4,7 +4,7 @@ class CountsTablesController < ApplicationController
|
|||||||
definition = CountsResourceDefinition.new(params[:resource])
|
definition = CountsResourceDefinition.new(params[:resource])
|
||||||
render json: { fields: definition.metadata,
|
render json: { fields: definition.metadata,
|
||||||
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
axes: { latest: definition.axes(false), history: definition.axes(true) },
|
||||||
grains: CountsAxes::GRAINS }
|
grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource]) }
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class CountsAxes
|
|||||||
end
|
end
|
||||||
|
|
||||||
def expressions
|
def expressions
|
||||||
all.map do |axis|
|
@expressions ||= all.map do |axis|
|
||||||
field = axis.fetch('field')
|
field = axis.fetch('field')
|
||||||
case field
|
case field
|
||||||
when 'tags'
|
when 'tags'
|
||||||
@@ -56,9 +56,9 @@ class CountsAxes
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def joins
|
def joins(fields = all.map { _1['field'] })
|
||||||
result = ''
|
result = ''
|
||||||
if all.any? { _1['field'] == 'tags' }
|
if fields.include?('tags')
|
||||||
result += <<~SQL.squish
|
result += <<~SQL.squish
|
||||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
||||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||||
@@ -66,7 +66,7 @@ class CountsAxes
|
|||||||
|
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
if all.any? { _1['field'] == 'changed_field' }
|
if fields.include?('changed_field')
|
||||||
fields = @definition.changes.map do |field|
|
fields = @definition.changes.map do |field|
|
||||||
"SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key"
|
"SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key"
|
||||||
end.join(' UNION ALL ')
|
end.join(' UNION ALL ')
|
||||||
@@ -89,6 +89,8 @@ class CountsAxes
|
|||||||
raise ArgumentError unless @definition.axes(@population.history).include?(axis['field'])
|
raise ArgumentError unless @definition.axes(@population.history).include?(axis['field'])
|
||||||
if @definition.fields[axis['field']] == :datetime
|
if @definition.fields[axis['field']] == :datetime
|
||||||
raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month'))
|
raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month'))
|
||||||
|
|
||||||
|
axis['grain'] ||= 'month'
|
||||||
elsif axis.key?('grain')
|
elsif axis.key?('grain')
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Only confirmed combinations belong here; all others retain the existing behaviour.
|
||||||
|
class CountsDefaults
|
||||||
|
POST_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'title', 'url',
|
||||||
|
'thumbnail_base', 'tags', 'parents', 'original_created_from',
|
||||||
|
'original_created_before', 'event_type', 'user', 'created_at'].freeze
|
||||||
|
|
||||||
|
def self.entries(resource)
|
||||||
|
result = [{ definition: 'history', mode: 'summary', params: {
|
||||||
|
'rows' => [{ field: 'created_at', grain: 'month' }].to_json,
|
||||||
|
'cols' => [{ field: 'event_type' }].to_json,
|
||||||
|
'sort' => 'a0:desc', 'conditions' => '[]' } }]
|
||||||
|
if resource == 'posts'
|
||||||
|
result += [{ definition: 'latest', mode: 'summary', params: {
|
||||||
|
'rows' => '[]', 'cols' => '[]', 'conditions' => '[]', 'tags' => '' } },
|
||||||
|
{ definition: 'latest', mode: 'details', params: {
|
||||||
|
'columns' => POST_COLUMNS.join(','), 'order' => POST_COLUMNS.join(','),
|
||||||
|
'children' => 'tags.name,parents.id', 'nested_json' => '0',
|
||||||
|
'conditions' => '[]', 'tags' => '' } }]
|
||||||
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.for(params)
|
||||||
|
settings = entries(params.fetch('resource', 'posts')).find do |entry|
|
||||||
|
entry[:definition] == params.fetch('definition', 'latest') &&
|
||||||
|
entry[:mode] == params.fetch('mode', 'summary')
|
||||||
|
end&.fetch(:params) || {}
|
||||||
|
if settings.key?('sort') && params.key?('rows')
|
||||||
|
actual = JSON.parse(params['rows'])
|
||||||
|
expected = JSON.parse(settings.fetch('rows'))
|
||||||
|
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
|
||||||
|
actual.first['field'] == expected.first['field'] &&
|
||||||
|
actual.first.fetch('grain', 'month') == expected.first['grain']
|
||||||
|
unless compatible
|
||||||
|
settings = settings.except('sort')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
settings
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.metadata(resource)
|
||||||
|
entries(resource).map do |entry|
|
||||||
|
entry.merge(params: entry[:params].map { |key, value| { key:, value: } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,7 +6,6 @@ class CountsExportProjection
|
|||||||
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
|
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
|
||||||
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
|
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
|
||||||
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
|
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
|
||||||
'original_created_at' => 'original_created_at',
|
|
||||||
'original_created_from' => 'original_created_from',
|
'original_created_from' => 'original_created_from',
|
||||||
'original_created_before' => 'original_created_before',
|
'original_created_before' => 'original_created_before',
|
||||||
'user' => 'created_by_user', 'created_at' => 'created_at',
|
'user' => 'created_by_user', 'created_at' => 'created_at',
|
||||||
@@ -43,6 +42,9 @@ class CountsExportProjection
|
|||||||
attr_reader :columns, :headers
|
attr_reader :columns, :headers
|
||||||
|
|
||||||
def initialize(params, resource:, summary:, tags:)
|
def initialize(params, resource:, summary:, tags:)
|
||||||
|
if params.key?('definition')
|
||||||
|
params = CountsDefaults.for(params.merge('resource' => resource)).merge(params)
|
||||||
|
end
|
||||||
@params = params
|
@params = params
|
||||||
@labels = JSON.parse(params.fetch('labels', '{}'))
|
@labels = JSON.parse(params.fetch('labels', '{}'))
|
||||||
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
|
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
|
||||||
@@ -103,8 +105,6 @@ class CountsExportProjection
|
|||||||
else
|
else
|
||||||
value = case key
|
value = case key
|
||||||
when 'thumbnail' then row[:thumbnail_base]
|
when 'thumbnail' then row[:thumbnail_base]
|
||||||
when 'original_created_at'
|
|
||||||
{ from: row[:original_created_from], before: row[:original_created_before] }
|
|
||||||
else row[ROOT_KEYS.fetch(key).to_sym]
|
else row[ROOT_KEYS.fetch(key).to_sym]
|
||||||
end
|
end
|
||||||
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
|
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Coordinates carry their aggregation depth; SQL NULL remains an ordinary axis value.
|
||||||
|
class CountsPivotLayout
|
||||||
|
def self.depths(keys, sorts, measures: true)
|
||||||
|
return [] if keys.length < 2 || sorts.first&.first == 'count' ||
|
||||||
|
sorts.first&.first&.start_with?('measure_')
|
||||||
|
|
||||||
|
relevant = sorts.map(&:first).select { keys.include?(_1) || (measures && (_1 == 'count' ||
|
||||||
|
_1.start_with?('measure_'))) }
|
||||||
|
order = (relevant + keys).uniq
|
||||||
|
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.headers(values, depths, next_values: nil, grand: false)
|
||||||
|
result = []
|
||||||
|
values.each_with_index do |tuple, index|
|
||||||
|
result << { values: tuple, kind: 'data' }
|
||||||
|
following = values[index + 1] || next_values
|
||||||
|
depths.reverse_each do |depth|
|
||||||
|
prefix = tuple.first(depth)
|
||||||
|
next if following && following.first(depth) == prefix
|
||||||
|
|
||||||
|
result << { values: prefix, kind: 'subtotal' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
result << { values: [], kind: 'grand_total' } if grand
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap).
|
||||||
|
class CountsPivotTotals
|
||||||
|
def initialize(population, axes, row_headers, column_headers)
|
||||||
|
@population = population
|
||||||
|
@axes = axes
|
||||||
|
@rows = row_headers.group_by { _1[:values].length }
|
||||||
|
@columns = column_headers.group_by { _1[:values].length }
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
queries = @rows.flat_map do |row_depth, headers|
|
||||||
|
@columns.filter_map do |column_depth, columns|
|
||||||
|
next if row_depth == @axes.rows.length && column_depth == @axes.columns.length
|
||||||
|
|
||||||
|
indexes = (0...row_depth).to_a +
|
||||||
|
(0...column_depth).map { @axes.rows.length + _1 }
|
||||||
|
expressions = indexes.map { @axes.expressions[_1] }
|
||||||
|
scope = @population.scope.except(:select, :order)
|
||||||
|
joins = @axes.joins(indexes.map { @axes.all[_1].fetch('field') })
|
||||||
|
scope = scope.joins(joins) unless joins.empty?
|
||||||
|
if row_depth.positive?
|
||||||
|
tuples = headers.map { _1[:values] }.uniq
|
||||||
|
predicate = tuples.map { Array.new(row_depth) { |i|
|
||||||
|
"(#{ expressions[i] }) <=> ?"
|
||||||
|
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
|
||||||
|
scope = scope.where(predicate, *tuples.flatten)
|
||||||
|
end
|
||||||
|
if column_depth.positive?
|
||||||
|
tuples = columns.map { _1[:values] }.uniq
|
||||||
|
predicate = tuples.map { Array.new(column_depth) { |i|
|
||||||
|
"(#{ expressions[row_depth + i] }) <=> ?"
|
||||||
|
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
|
||||||
|
scope = scope.where(predicate, *tuples.flatten)
|
||||||
|
end
|
||||||
|
grouped = expressions.each_with_index.map { |expression, index|
|
||||||
|
"#{ expression } AS v#{ index }"
|
||||||
|
}
|
||||||
|
# Fixed-width result coordinates, independent of NULL and of translated labels.
|
||||||
|
padding = (expressions.length...@axes.all.length).map { "NULL AS v#{ _1 }" }
|
||||||
|
fields = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
||||||
|
grouped + padding + ['COUNT(DISTINCT v.id) AS count']
|
||||||
|
scope = scope.group(*expressions.map { Arel.sql(_1) }) if expressions.any?
|
||||||
|
scope.optimizer_hints('MAX_EXECUTION_TIME(10000)')
|
||||||
|
.select(Arel.sql(fields.join(', '))).to_sql
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return [] if queries.empty?
|
||||||
|
|
||||||
|
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
|
||||||
|
@population.definition.model.connection.select_all(
|
||||||
|
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
|
||||||
|
row_depth = record['row_depth'].to_i
|
||||||
|
column_depth = record['column_depth'].to_i
|
||||||
|
{ row: (0...row_depth).map { record["v#{ _1 }"] },
|
||||||
|
column: (0...column_depth).map { record["v#{ row_depth + _1 }"] },
|
||||||
|
count: record['count'].to_i }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,7 +5,7 @@ class CountsQuery
|
|||||||
COLUMNS = {
|
COLUMNS = {
|
||||||
'posts' => COMMON_COLUMNS + [
|
'posts' => COMMON_COLUMNS + [
|
||||||
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
|
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
|
||||||
'original_created_at', 'original_created_from', 'original_created_before'],
|
'original_created_from', 'original_created_before'],
|
||||||
'tags' => COMMON_COLUMNS + [
|
'tags' => COMMON_COLUMNS + [
|
||||||
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
||||||
KEYS = [
|
KEYS = [
|
||||||
@@ -124,8 +124,6 @@ class CountsQuery
|
|||||||
'title', 'url', 'thumbnail_base', 'video_ms',
|
'title', 'url', 'thumbnail_base', 'video_ms',
|
||||||
'original_created_from', 'original_created_before']
|
'original_created_from', 'original_created_before']
|
||||||
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
|
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
|
||||||
'original_created_at' => [
|
|
||||||
'post_versions.original_created_from', 'post_versions.original_created_before'],
|
|
||||||
'tags' => json_order('post_versions.tags_json'),
|
'tags' => json_order('post_versions.tags_json'),
|
||||||
'parents' => list_order('post_versions.parent_post_ids'))
|
'parents' => list_order('post_versions.parent_post_ids'))
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class CountsTableExport
|
|||||||
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
|
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
|
||||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
|
params = CountsDefaults.for(params).merge(params)
|
||||||
unless params.key?('children')
|
unless params.key?('children')
|
||||||
params = params.merge('children' => CountsExportProjection.default_children(
|
params = params.merge('children' => CountsExportProjection.default_children(
|
||||||
params.fetch('resource', 'posts')).join(','))
|
params.fetch('resource', 'posts')).join(','))
|
||||||
@@ -63,7 +64,8 @@ class CountsTableExport
|
|||||||
total = @summary ? nil : page[:count]
|
total = @summary ? nil : page[:count]
|
||||||
progress&.call(processed, total)
|
progress&.call(processed, total)
|
||||||
unless started
|
unless started
|
||||||
metadata = page.except(:versions, :cells, :axis_labels, :column_values).merge(
|
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
||||||
|
:row_headers, :column_headers, :pivot_totals).merge(
|
||||||
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
|
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
|
||||||
fields: @query.population.definition.metadata,
|
fields: @query.population.definition.metadata,
|
||||||
columns: @columns, column_labels: @headers,
|
columns: @columns, column_labels: @headers,
|
||||||
@@ -71,12 +73,35 @@ class CountsTableExport
|
|||||||
sort: @params.fetch('sort', ''), child_sort: @params['child_sort'])
|
sort: @params.fetch('sort', ''), child_sort: @params['child_sort'])
|
||||||
output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF")
|
output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF")
|
||||||
if format == 'csv'
|
if format == 'csv'
|
||||||
headers = @summary ? summary_headers : @headers
|
headings = @summary ? pivot_headers(page) : [@headers]
|
||||||
|
headings.each do |headers|
|
||||||
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
|
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
|
||||||
end
|
end
|
||||||
|
end
|
||||||
started = true
|
started = true
|
||||||
end
|
end
|
||||||
(page[:cells] || page[:versions]).each do |row|
|
if @summary
|
||||||
|
pivot_rows(page).each do |header|
|
||||||
|
records = page.fetch(:column_headers).map { pivot_cell(header, _1, page) }
|
||||||
|
if format == 'csv'
|
||||||
|
labels = pivot_row_labels(header, page)
|
||||||
|
values = labels + records.map { _1[:count] }
|
||||||
|
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
|
||||||
|
processed += records.length
|
||||||
|
progress&.call(processed, nil)
|
||||||
|
else
|
||||||
|
records.each do |record|
|
||||||
|
output << ',' unless first
|
||||||
|
output << record.to_json
|
||||||
|
first = false
|
||||||
|
processed += 1
|
||||||
|
progress&.call(processed, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
next
|
||||||
|
end
|
||||||
|
page[:versions].each do |row|
|
||||||
record = project(row)
|
record = project(row)
|
||||||
if format == 'json'
|
if format == 'json'
|
||||||
output << ',' unless first
|
output << ',' unless first
|
||||||
@@ -106,12 +131,76 @@ class CountsTableExport
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def summary_headers
|
def pivot_rows(page)
|
||||||
return @headers if @headers.any?
|
page.fetch(:row_headers).reject do |header|
|
||||||
|
header[:kind] == 'grand_total' && @query.page * @query.limit < page[:group_count]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
labels = @labels
|
def total_label(header)
|
||||||
@query.axes.all.map { labels.fetch(_1['field'], _1['field']) } +
|
@labels.fetch(header[:kind] == 'grand_total' ? 'grandTotal' : 'subtotal', '')
|
||||||
[labels.fetch('count', 'count')]
|
end
|
||||||
|
|
||||||
|
def pivot_row_labels(header, page)
|
||||||
|
values = header[:values].each_with_index.map { |value, index|
|
||||||
|
summary_value(value, index, page)
|
||||||
|
}
|
||||||
|
values << total_label(header) unless header[:kind] == 'data'
|
||||||
|
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
|
||||||
|
Array.new(leading) { values[_1] || '' }
|
||||||
|
end
|
||||||
|
|
||||||
|
def pivot_headers(page)
|
||||||
|
row_count = @query.axes.rows.length
|
||||||
|
cols = @query.axes.columns
|
||||||
|
leading = [row_count, cols.any? ? 1 : 0].max
|
||||||
|
columns = page.fetch(:column_headers)
|
||||||
|
Array.new([cols.length, 1].max) do |level|
|
||||||
|
prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' }
|
||||||
|
prefix = [''] if row_count.zero? && cols.any?
|
||||||
|
prefix + columns.each_with_index.map do |header, index|
|
||||||
|
values = header[:values]
|
||||||
|
if cols.empty?
|
||||||
|
header[:kind] == 'data' ? @labels.fetch('count', '') : total_label(header)
|
||||||
|
elsif level == values.length && header[:kind] != 'data'
|
||||||
|
total_label(header)
|
||||||
|
elsif level < values.length
|
||||||
|
previous = index.positive? ? columns[index - 1][:values] : nil
|
||||||
|
if level < cols.length - 1 && previous && previous.first(level + 1) == values.first(level + 1)
|
||||||
|
''
|
||||||
|
else
|
||||||
|
summary_value(values[level], row_count + level, page)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
''
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def pivot_cell(row, column, page)
|
||||||
|
@pivot_lookup ||= {}
|
||||||
|
# Discard the previous page's lookup; memory remains bounded by one page and its rollups.
|
||||||
|
unless @pivot_page.equal?(page)
|
||||||
|
@pivot_page = page
|
||||||
|
@pivot_lookup = page.fetch(:cells).to_h do |cell|
|
||||||
|
values = cell[:values]
|
||||||
|
[[values.first(@query.axes.rows.length), values.drop(@query.axes.rows.length)], cell[:count]]
|
||||||
|
end
|
||||||
|
page.fetch(:pivot_totals).each { @pivot_lookup[[_1[:row], _1[:column]]] = _1[:count] }
|
||||||
|
end
|
||||||
|
kinds = [row[:kind], column[:kind]]
|
||||||
|
kind = if kinds.include?('grand_total')
|
||||||
|
'grand_total'
|
||||||
|
elsif kinds.include?('subtotal')
|
||||||
|
'subtotal'
|
||||||
|
else
|
||||||
|
'data'
|
||||||
|
end
|
||||||
|
{ row: row[:values], column: column[:values], kind:,
|
||||||
|
row_kind: row[:kind], column_kind: column[:kind],
|
||||||
|
row_depth: row[:values].length, column_depth: column[:values].length,
|
||||||
|
count: @pivot_lookup.fetch([row[:values], column[:values]], 0) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def summary_value(value, index, page)
|
def summary_value(value, index, page)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class CountsTableQuery
|
|||||||
raise ArgumentError if (params.keys - KEYS).any?
|
raise ArgumentError if (params.keys - KEYS).any?
|
||||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
|
params = CountsDefaults.for(params).slice(*KEYS).merge(params)
|
||||||
@params = params
|
@params = params
|
||||||
@population = CountsPopulation.new(params)
|
@population = CountsPopulation.new(params)
|
||||||
@definition = population.definition
|
@definition = population.definition
|
||||||
@@ -26,7 +27,7 @@ class CountsTableQuery
|
|||||||
@sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) }
|
@sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) }
|
||||||
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
||||||
@definition.fields.keys + ['is_latest'] +
|
@definition.fields.keys + ['is_latest'] +
|
||||||
(@definition.resource == 'posts' ? ['tags', 'parents', 'original_created_at'] : []) +
|
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
||||||
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
||||||
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
||||||
@sort.each do |key, direction, *extra|
|
@sort.each do |key, direction, *extra|
|
||||||
@@ -72,25 +73,26 @@ class CountsTableQuery
|
|||||||
# Page whole row tuples so a pivot row is never split across display pages.
|
# Page whole row tuples so a pivot row is never split across display pages.
|
||||||
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
|
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
|
||||||
row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }"
|
row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }"
|
||||||
|
measure_joins = []
|
||||||
|
extras = []
|
||||||
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
|
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
|
||||||
if key == 'count' || key.start_with?('measure_')
|
if key == 'count' || key.start_with?('measure_')
|
||||||
condition = if key == 'count'
|
values = key == 'count' ? [] : measure_values(key)
|
||||||
'TRUE'
|
relation = measure_relation(values)
|
||||||
else
|
matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" }
|
||||||
measure_values(key).each_with_index.map do |value, offset|
|
condition = matches.empty? ? 'TRUE' : matches.join(' AND ')
|
||||||
"a#{ axes.rows.length + offset } <=> #{ @connection.quote(value) }"
|
measure_joins << "LEFT JOIN (#{ relation }) sort#{ index } ON #{ condition }"
|
||||||
end.join(' AND ').presence || 'TRUE'
|
extras << "sort#{ index }.count AS sort#{ index }"
|
||||||
end
|
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"
|
||||||
["MAX(CASE WHEN #{ condition } THEN count END) AS sort#{ index }",
|
|
||||||
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"]
|
|
||||||
elsif row_keys.include?(key)
|
elsif row_keys.include?(key)
|
||||||
[nil, "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"]
|
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
extras = row_sort.filter_map(&:first)
|
order_rows = row_sort + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
|
||||||
order_rows = row_sort.map(&:last) + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
|
|
||||||
order_rows = ['singleton ASC'] if order_rows.empty?
|
order_rows = ['singleton ASC'] if order_rows.empty?
|
||||||
group_sql = "SELECT #{ ([row_selection] + extras).join(', ') } FROM cells #{ row_group }"
|
grouped = "SELECT #{ row_selection } FROM cells #{ row_group }"
|
||||||
|
selection = (['grouped.*'] + extras).join(', ')
|
||||||
|
group_sql = "SELECT #{ selection } FROM (#{ grouped }) grouped #{ measure_joins.join(' ') }"
|
||||||
total = @connection.select_value(<<~SQL).to_i
|
total = @connection.select_value(<<~SQL).to_i
|
||||||
#{ prefix }
|
#{ prefix }
|
||||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups
|
||||||
@@ -113,7 +115,30 @@ class CountsTableQuery
|
|||||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join }
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join }
|
||||||
ORDER BY #{ order.join(', ') }
|
ORDER BY #{ order.join(', ') }
|
||||||
SQL
|
SQL
|
||||||
{ group_count: total, column_values:, cells: records.map do |row|
|
next_row = @connection.select_one(<<~SQL)
|
||||||
|
#{ prefix }, row_groups AS (#{ group_sql }),
|
||||||
|
numbered AS (SELECT row_groups.*,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups)
|
||||||
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM numbered
|
||||||
|
WHERE position = #{ page * limit + 1 }
|
||||||
|
SQL
|
||||||
|
row_values = records.map { |record| row_keys.map { record[_1] } }.uniq
|
||||||
|
row_depths = CountsPivotLayout.depths(row_keys, @sort)
|
||||||
|
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
||||||
|
column_depths = CountsPivotLayout.depths(column_keys, @sort, measures: false)
|
||||||
|
row_headers = CountsPivotLayout.headers(row_values, row_depths,
|
||||||
|
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
|
||||||
|
if axes.rows.empty? && axes.columns.any?
|
||||||
|
row_headers = [{ values: [], kind: 'grand_total' }]
|
||||||
|
end
|
||||||
|
column_headers = CountsPivotLayout.headers(column_values, column_depths,
|
||||||
|
grand: axes.columns.any?)
|
||||||
|
if axes.columns.empty? && axes.rows.any?
|
||||||
|
column_headers = [{ values: [], kind: 'grand_total' }]
|
||||||
|
end
|
||||||
|
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
|
||||||
|
{ group_count: total, column_values:, row_headers:, column_headers:, pivot_totals: totals,
|
||||||
|
cells: records.map do |row|
|
||||||
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
|
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
|
||||||
end, axis_labels: axis_labels(records, column_values) }
|
end, axis_labels: axis_labels(records, column_values) }
|
||||||
end
|
end
|
||||||
@@ -167,7 +192,11 @@ class CountsTableQuery
|
|||||||
keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
||||||
return [[]] if keys.empty?
|
return [[]] if keys.empty?
|
||||||
|
|
||||||
order = keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ')
|
configured = @sort.select { |key, _direction| keys.include?(key) }
|
||||||
|
directions = (configured + keys.map { [_1, 'asc'] }).uniq { _1.first }
|
||||||
|
order = directions.map { |key, direction|
|
||||||
|
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||||
|
}.join(', ')
|
||||||
values = @connection.select_rows(<<~SQL)
|
values = @connection.select_rows(<<~SQL)
|
||||||
#{ prefix }
|
#{ prefix }
|
||||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
|
||||||
@@ -178,9 +207,26 @@ class CountsTableQuery
|
|||||||
values
|
values
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def measure_relation(values)
|
||||||
|
row_expressions = axes.expressions.first(axes.rows.length)
|
||||||
|
source = population.scope.except(:select, :order)
|
||||||
|
fields = axes.rows + axes.columns.first(values.length)
|
||||||
|
joins = axes.joins(fields.map { _1.fetch('field') })
|
||||||
|
source = source.joins(joins) unless joins.empty?
|
||||||
|
values.each_with_index do |value, index|
|
||||||
|
expression = axes.expressions[axes.rows.length + index]
|
||||||
|
source = source.where("(#{ expression }) <=> ?", value)
|
||||||
|
end
|
||||||
|
selected = row_expressions.each_with_index.map { |expression, index|
|
||||||
|
"#{ expression } AS a#{ index }"
|
||||||
|
} + ['COUNT(DISTINCT v.id) AS count']
|
||||||
|
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
|
||||||
|
source.select(Arel.sql(selected.join(', '))).to_sql
|
||||||
|
end
|
||||||
|
|
||||||
def measure_values(key)
|
def measure_values(key)
|
||||||
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
|
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
|
||||||
unless (value.is_a?(Array) && value.length == axes.columns.length &&
|
unless (value.is_a?(Array) && value.length <= axes.columns.length &&
|
||||||
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
|
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
@@ -190,8 +236,7 @@ class CountsTableQuery
|
|||||||
def detail_expression(key)
|
def detail_expression(key)
|
||||||
return "(#{ population.latest_sql })" if key == 'is_latest'
|
return "(#{ population.latest_sql })" if key == 'is_latest'
|
||||||
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
|
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
|
||||||
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at',
|
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' }
|
||||||
'original_created_at' => 'original_created_from' }
|
|
||||||
field = aliases.fetch(key, key)
|
field = aliases.fetch(key, key)
|
||||||
expression = @definition.expression(field)
|
expression = @definition.expression(field)
|
||||||
return expression unless @definition.fields[field] == :json
|
return expression unless @definition.fields[field] == :json
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useId, useState } from 'react'
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
|
||||||
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
|
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
|
||||||
import CountsAxes from '@/components/counts/CountsAxes'
|
import CountsAxes from '@/components/counts/CountsAxes'
|
||||||
@@ -9,10 +9,10 @@ import FormField from '@/components/common/FormField'
|
|||||||
import HelpPopover from '@/components/common/HelpPopover'
|
import HelpPopover from '@/components/common/HelpPopover'
|
||||||
import TagInput from '@/components/common/TagInput'
|
import TagInput from '@/components/common/TagInput'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsUseLatest, fieldsForCounts, normaliseCountsParams,
|
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
||||||
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsNestedIsScalar } from '@/lib/countsDisplay'
|
import { countsNestedIsScalar } from '@/lib/countsDisplay'
|
||||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
import { readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
@@ -42,6 +42,7 @@ const CountsControls = (
|
|||||||
const nestedJSON = applied.get ('nested_json')
|
const nestedJSON = applied.get ('nested_json')
|
||||||
const rowNumbers = applied.get ('row_numbers')
|
const rowNumbers = applied.get ('row_numbers')
|
||||||
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
|
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
|
||||||
|
const previousThumbnail = useRef (thumbnail)
|
||||||
const mode = draft.get ('mode') ?? 'summary'
|
const mode = draft.get ('mode') ?? 'summary'
|
||||||
const columns = selectedCountColumns (resource, draft, definition)
|
const columns = selectedCountColumns (resource, draft, definition)
|
||||||
const details = mode === 'details'
|
const details = mode === 'details'
|
||||||
@@ -50,8 +51,10 @@ const CountsControls = (
|
|||||||
&& !countsNestedIsScalar (field.children, field.key, draft)
|
&& !countsNestedIsScalar (field.children, field.key, draft)
|
||||||
&& field.children.some (child => selectedCountChildren (draft)
|
&& field.children.some (child => selectedCountChildren (draft)
|
||||||
.has (`${field.key}.${child.key}`)))
|
.has (`${field.key}.${child.key}`)))
|
||||||
// Immediate presentation changes merge into the pending query, never replace it.
|
// Preserve absent columns until a real presentation change or an explicit edit.
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
if (previousThumbnail.current === thumbnail) return
|
||||||
|
previousThumbnail.current = thumbnail
|
||||||
setDraft (previous => {
|
setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
const columns = selectedCountColumns (resource, previous, definition)
|
const columns = selectedCountColumns (resource, previous, definition)
|
||||||
@@ -75,16 +78,26 @@ const CountsControls = (
|
|||||||
const history = draft.get ('definition') === 'history'
|
const history = draft.get ('definition') === 'history'
|
||||||
const update = (key: string, value: string) => setDraft (previous => {
|
const update = (key: string, value: string) => setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
if (['mode', 'rows', 'cols'].includes (key)) next.delete ('sort')
|
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
||||||
next.set (key, value)
|
next.set (key, value)
|
||||||
return next
|
if (key !== 'mode') return next
|
||||||
|
const resolved = normaliseCountsParams (resource, next, definition)
|
||||||
|
const axes = [...readCountsJSON<unknown[]> (resolved, 'rows', []),
|
||||||
|
...readCountsJSON<unknown[]> (resolved, 'cols', [])]
|
||||||
|
const allowed = value === 'summary'
|
||||||
|
? ['count', ...axes.map ((_, index) => `a${index}`)]
|
||||||
|
: fieldsForCounts (resource, definition).map (field => field.key)
|
||||||
|
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key)
|
||||||
|
&& !(value === 'summary' && /^measure_[0-9a-f]+$/.test (sort.key))))
|
||||||
|
next.delete ('sort')
|
||||||
|
return normaliseCountsParams (resource, next, definition)
|
||||||
})
|
})
|
||||||
return <form className="shrink-0" onSubmit={event => {
|
return <form className="shrink-0" onSubmit={event => {
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
const next = new URLSearchParams (draft)
|
const next = new URLSearchParams (draft)
|
||||||
next.delete ('page')
|
next.delete ('page')
|
||||||
next.delete ('execution_at')
|
next.delete ('execution_at')
|
||||||
execute (normaliseCountsParams (resource, next))
|
execute (normaliseCountsParams (resource, next, definition))
|
||||||
}}>
|
}}>
|
||||||
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
|
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
|
||||||
dark:border-slate-700">
|
dark:border-slate-700">
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
|||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
|
||||||
const CountsRowNumber = ({ value }: { value?: number }) => {
|
const CountsRowNumber = ({ value, rowSpan }: { value?: number; rowSpan?: number }) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
if (value != null) return <td data-counts-copy={String (value)}
|
if (value != null) return <td data-counts-copy={String (value)}
|
||||||
className="border-b px-2 py-1 text-right align-top tabular-nums dark:border-slate-700">
|
className="border-b px-2 py-1 text-right align-top tabular-nums dark:border-slate-700">
|
||||||
{value}</td>
|
{value}</td>
|
||||||
return <th data-counts-heading={message ('rowNumbers').text} className="relative px-2 py-1">
|
return <th rowSpan={rowSpan} data-counts-heading={message ('rowNumberHeading').text} className="relative px-2 py-1">
|
||||||
{label ('rowNumbers')}
|
{label ('rowNumberHeading')}
|
||||||
<CountsResizeHandle field="row_number" label={message ('rowNumbers')}/>
|
<CountsResizeHandle field="row_number" label={message ('rowNumberHeading')}/>
|
||||||
</th>
|
</th>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const CountsSelectableTable = (
|
|||||||
const table = copyTable ()
|
const table = copyTable ()
|
||||||
return countsClipboard ({
|
return countsClipboard ({
|
||||||
columns: table.columns.slice (bounds.left, bounds.right + 1),
|
columns: table.columns.slice (bounds.left, bounds.right + 1),
|
||||||
|
headerRows: table.headerRows?.map (row => row.slice (bounds.left, bounds.right + 1)),
|
||||||
rows: table.rows.slice (bounds.top, bounds.bottom + 1)
|
rows: table.rows.slice (bounds.top, bounds.bottom + 1)
|
||||||
.map (row => row.slice (bounds.left, bounds.right + 1)) }, headers)
|
.map (row => row.slice (bounds.left, bounds.right + 1)) }, headers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import type { CountSort } from '@/lib/counts'
|
|||||||
|
|
||||||
|
|
||||||
const CountsSortHeader = (
|
const CountsSortHeader = (
|
||||||
{ field, label, sorts, change, structured = false }: {
|
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true }: {
|
||||||
field: string
|
field: string
|
||||||
label: { text: string; language: string }
|
label: { text: string; language: string }
|
||||||
sorts: CountSort[]
|
sorts: CountSort[]
|
||||||
change: (key: string, additive: boolean) => void
|
change: (key: string, additive: boolean) => void
|
||||||
structured?: boolean },
|
structured?: boolean
|
||||||
|
rowSpan?: number; colSpan?: number; resizable?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const press = useRef<{ x: number; y: number
|
const press = useRef<{ x: number; y: number
|
||||||
@@ -36,7 +37,7 @@ const CountsSortHeader = (
|
|||||||
direction: message (`sort.${state}`).text })
|
direction: message (`sort.${state}`).text })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th data-counts-heading={label.text}
|
<th data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
|
||||||
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
||||||
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
||||||
<Button type="button" variant="ghost" size="sm"
|
<Button type="button" variant="ghost" size="sm"
|
||||||
@@ -89,7 +90,7 @@ const CountsSortHeader = (
|
|||||||
{priority >= 0 && <span aria-hidden>
|
{priority >= 0 && <span aria-hidden>
|
||||||
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
|
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<CountsResizeHandle field={field} label={label}/>
|
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
||||||
</th>)
|
</th>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
|||||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||||
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
|
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { CountsResource, CountsResult } from '@/lib/counts'
|
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
|
||||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
@@ -19,25 +20,26 @@ const CountsSummaryResults = (
|
|||||||
) => {
|
) => {
|
||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const rowNumbers = params.get ('row_numbers') === '1'
|
const numbered = params.get ('row_numbers') === '1'
|
||||||
const rows = data.rows ?? []
|
const rows = data.rows ?? []
|
||||||
const cols = data.cols ?? []
|
const cols = data.cols ?? []
|
||||||
const axes = [...rows, ...cols]
|
|
||||||
const cells = data.cells ?? []
|
const cells = data.cells ?? []
|
||||||
const rowKeys = [...new Set (cells.map (cell => JSON.stringify (
|
const rowHeaders: CountsPivotHeader[] = data.rowHeaders ?? [...new Set (
|
||||||
cell.values.slice (0, rows.length))))]
|
cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))]
|
||||||
const columnKeys = data.columnValues?.map (values => JSON.stringify (values))
|
.map (key => ({ values: JSON.parse (key), kind: 'data' }))
|
||||||
?? [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (rows.length))))]
|
const columnHeaders: CountsPivotHeader[] = data.columnHeaders
|
||||||
if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]')
|
?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' }))
|
||||||
const sorts = readCountSort (params.get ('sort'))
|
const sorts = readCountSort (params.get ('sort'))
|
||||||
|
const change = (key: string, additive: boolean) => update (
|
||||||
|
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||||
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
|
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
|
||||||
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
|
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
|
||||||
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
|
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
|
||||||
if (axis.field === 'user') return message ('actorWithId', {
|
if (axis.field === 'user') return message ('actorWithId', {
|
||||||
name: logical || message ('notRegistered').text, id: value }).text
|
name: logical || message ('notRegistered').text, id: value }).text
|
||||||
if (logical != null) return logical
|
if (logical != null) return logical
|
||||||
if (['tags', 'user', 'language_code', 'source_language_code'].includes (axis.field))
|
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
|
||||||
return message ('notRegistered').text
|
return message ('notRegistered').text
|
||||||
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
|
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
|
||||||
if (axis.field === 'event_type') return message (`events.${value}`).text
|
if (axis.field === 'event_type') return message (`events.${value}`).text
|
||||||
@@ -45,71 +47,140 @@ const CountsSummaryResults = (
|
|||||||
return message (`common:categories.${value}`).text
|
return message (`common:categories.${value}`).text
|
||||||
return String (value)
|
return String (value)
|
||||||
}
|
}
|
||||||
const drill = (values: (string | number | null)[]) => {
|
const aggregateLabel = (header: CountsPivotHeader) => message (
|
||||||
|
header.kind === 'grand_total' ? 'grandTotal' : 'subtotal').text
|
||||||
|
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
|
||||||
|
const levels = Math.max (1, cols.length)
|
||||||
|
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
||||||
|
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
||||||
|
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
||||||
|
const measureKey = (header: CountsPivotHeader) => header.values.length === 0 ? 'count'
|
||||||
|
: `measure_${[...new TextEncoder ().encode (JSON.stringify (header.values))]
|
||||||
|
.map (byte => byte.toString (16).padStart (2, '0')).join ('')}`
|
||||||
|
const keys = [...(numbered ? ['row_number'] : []),
|
||||||
|
...Array.from ({ length: leading }, (_, index) => `a${index}`),
|
||||||
|
...columnHeaders.map (columnKey)]
|
||||||
|
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
|
||||||
|
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
|
||||||
|
JSON.stringify ([cell.row, cell.column]), cell.count]))
|
||||||
|
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
|
||||||
|
(row.values.length === rows.length && column.values.length === cols.length)
|
||||||
|
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
|
||||||
|
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
||||||
|
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
const existing = readCountsJSON<unknown[]> (params, 'drill', [])
|
const existing = readCountsJSON<unknown[]> (params, 'drill', [])
|
||||||
next.set ('drill', JSON.stringify ([...existing,
|
next.set ('drill', JSON.stringify ([...existing,
|
||||||
...axes.map ((axis, index) => ({ ...axis, value: values[index] }))]))
|
...row.values.map ((value, index) => ({ ...rows[index], value })),
|
||||||
|
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
|
||||||
next.set ('mode', 'details')
|
next.set ('mode', 'details')
|
||||||
next.delete ('sort')
|
next.delete ('sort')
|
||||||
next.delete ('page')
|
next.delete ('page')
|
||||||
return `/counts/${resource}?${next}`
|
return `/counts/${resource}?${next}`
|
||||||
}
|
}
|
||||||
const keys = [...(rowNumbers ? ['row_number'] : []), ...rows.map ((_, index) => `a${index}`),
|
let ordinal = (data.page - 1) * data.limit
|
||||||
...columnKeys.map ((_, index) => `cell${index}`)]
|
const rowLabels = rowHeaders.map (header => {
|
||||||
|
const labels = names (header, rows, 0)
|
||||||
|
const number = header.kind === 'data' ? String (++ordinal) : ''
|
||||||
|
if (header.kind !== 'data') labels.push (aggregateLabel (header))
|
||||||
|
return [...(numbered ? [number] : []),
|
||||||
|
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
||||||
|
})
|
||||||
|
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
||||||
|
const headingRows = Array.from ({ length: levels }, (_, level) => [
|
||||||
|
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
||||||
|
...Array.from ({ length: leading }, (_, index) =>
|
||||||
|
level === 0 && rows[index] != null
|
||||||
|
? countsAxisLabel (rows[index], definition, locale).text : ''),
|
||||||
|
...columnHeaders.map ((header, index) => {
|
||||||
|
if (cols.length === 0) return header.kind === 'data'
|
||||||
|
? message ('count').text : aggregateLabel (header)
|
||||||
|
if (level > header.values.length) return ''
|
||||||
|
if (level === header.values.length)
|
||||||
|
return header.kind === 'data' ? '' : aggregateLabel (header)
|
||||||
|
const prefix = header.values.slice (0, level + 1)
|
||||||
|
const previous = columnHeaders[index - 1]
|
||||||
|
if (level < levels - 1 && previous != null
|
||||||
|
&& JSON.stringify (previous.values.slice (0, level + 1)) === JSON.stringify (prefix))
|
||||||
|
return ''
|
||||||
|
return display (header.values[level], cols[level], rows.length + level)
|
||||||
|
})])
|
||||||
|
const matrix = rowHeaders.map ((header, index) => [...rowLabels[index],
|
||||||
|
...columnHeaders.map (column => String (count (header, column)))])
|
||||||
|
const headerCells = (level: number) => {
|
||||||
|
const output = []
|
||||||
|
for (let index = 0; index < columnHeaders.length; ++index)
|
||||||
|
{
|
||||||
|
const header = columnHeaders[index]
|
||||||
|
if (cols.length > 0 && level > header.values.length) continue
|
||||||
|
const aggregate = header.kind !== 'data' && level === header.values.length
|
||||||
|
const leaf = level === levels - 1 || aggregate
|
||||||
|
const text = aggregate ? aggregateLabel (header)
|
||||||
|
: (cols.length === 0 ? message ('count').text
|
||||||
|
: display (header.values[level], cols[level], rows.length + level))
|
||||||
|
let span = 1
|
||||||
|
if (!leaf)
|
||||||
|
{
|
||||||
|
const prefix = JSON.stringify (header.values.slice (0, level + 1))
|
||||||
|
while (index + span < columnHeaders.length)
|
||||||
|
{
|
||||||
|
const sibling = columnHeaders[index + span]
|
||||||
|
if (sibling.values.length <= level
|
||||||
|
|| JSON.stringify (sibling.values.slice (0, level + 1)) !== prefix) break
|
||||||
|
++span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sortKey = leaf ? measureKey (header) : `a${rows.length + level}`
|
||||||
|
const field = leaf ? columnKey (header) : sortKey
|
||||||
|
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
||||||
|
colSpan={span} rowSpan={aggregate ? levels - level : 1} resizable={leaf}
|
||||||
|
label={{ text, language: message ('count').language }}
|
||||||
|
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
|
||||||
|
change={(_, additive) => change (sortKey, additive)}/>)
|
||||||
|
index += span - 1
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||||
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
|
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
|
||||||
identity={`${countsRequestParams (params)}:${keys}`} className="text-left text-sm">
|
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
|
||||||
<thead className="sticky top-0 z-20"><tr>
|
headerRows: headingRows, rows: matrix })}
|
||||||
{rowNumbers && <CountsRowNumber/>}
|
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
|
||||||
{rows.map ((axis, index) => <CountsSortHeader key={axis.field} field={`a${index}`}
|
className="text-left text-sm">
|
||||||
label={countsAxisLabel (axis, definition, locale)} sorts={sorts}
|
<thead className="sticky top-0 z-20">
|
||||||
change={(key, additive) => update ('sort', writeCountSort (
|
{headingRows.map ((_, level) => <tr key={level}>
|
||||||
changeCountSort (sorts, key, additive)))}/>)}
|
{level === 0 && <>
|
||||||
{columnKeys.map ((key, index) => {
|
{numbered && <CountsRowNumber rowSpan={levels}/>}
|
||||||
const values = JSON.parse (key) as (string | number | null)[]
|
{Array.from ({ length: leading }, (_, index) => rows[index] == null
|
||||||
const name = values.map ((value, position) =>
|
? <th key={index} rowSpan={levels}/>
|
||||||
`${countsAxisLabel (cols[position], definition, locale).text}: ${
|
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
|
||||||
display (value, cols[position], rows.length + position)}`).join (' / ')
|
rowSpan={levels} label={countsAxisLabel (rows[index], definition, locale)}
|
||||||
const sortKey = cols.length === 0 ? 'count' : `measure_${[
|
sorts={sorts} change={change}/>)}
|
||||||
...new TextEncoder ().encode (key)].map (byte =>
|
</>}
|
||||||
byte.toString (16).padStart (2, '0')).join ('')}`
|
{headerCells (level)}
|
||||||
return <CountsSortHeader key={key} field={`cell${index}`}
|
</tr>)}
|
||||||
label={{ text: name || message ('count').text,
|
</thead>
|
||||||
language: message ('count').language }}
|
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||||
sorts={sorts.map (sort => sort.key === sortKey
|
{rowLabels[index].map ((value, offset) => <td key={offset} data-counts-copy={value}
|
||||||
? { ...sort, key: `cell${index}` } : sort)}
|
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
||||||
change={(_, additive) => update ('sort', writeCountSort (
|
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
||||||
changeCountSort (sorts, sortKey, additive)))}/>
|
row.kind !== 'data' && 'bg-slate-200 text-slate-950 font-semibold',
|
||||||
})}
|
row.kind !== 'data' && 'dark:bg-slate-700 dark:text-slate-50',
|
||||||
</tr></thead>
|
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>)}
|
||||||
<tbody>{rowKeys.map ((rowKey, rowIndex) => {
|
{columnHeaders.map (column => <td key={columnKey (column)}
|
||||||
const rowValues = JSON.parse (rowKey) as (string | number | null)[]
|
data-counts-copy={String (count (row, column))}
|
||||||
return <tr key={rowKey}>
|
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||||
{rowNumbers && <CountsRowNumber value={(data.page - 1) * data.limit + rowIndex + 1}/>}
|
? '' : undefined}
|
||||||
{rowValues.map ((value, index) => <td key={index}
|
className={cn ('border px-2 py-1 dark:border-slate-700',
|
||||||
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700"
|
(row.kind !== 'data' || column.kind !== 'data')
|
||||||
data-counts-copy={display (value, rows[index], index)}
|
&& 'bg-slate-200 text-slate-950 font-semibold',
|
||||||
lang={(value == null && rows[index].field === 'user')
|
(row.kind !== 'data' || column.kind !== 'data')
|
||||||
? message ('bot').language : (rows[index].field === 'event_type'
|
&& 'dark:bg-slate-700 dark:text-slate-50')}>
|
||||||
? message (`events.${value}`).language : undefined)}>
|
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
|
||||||
{display (value, rows[index], index)}</td>)}
|
</td>)}
|
||||||
{columnKeys.map (columnKey => {
|
</tr>)}</tbody>
|
||||||
const columnValues = JSON.parse (columnKey) as (string | number | null)[]
|
|
||||||
const values = [...rowValues, ...columnValues]
|
|
||||||
const cell = cells.find (cell => JSON.stringify (
|
|
||||||
cell.values) === JSON.stringify (values))
|
|
||||||
return <td key={columnKey} data-counts-copy={String (cell?.count ?? 0)}
|
|
||||||
className="border px-2 py-1 dark:border-slate-700">
|
|
||||||
<PrefetchLink to={drill (values)}>{cell?.count ?? 0}</PrefetchLink>
|
|
||||||
</td>
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
})}</tbody>
|
|
||||||
</CountsSelectableTable>
|
</CountsSelectableTable>
|
||||||
{cells.length === 0 && <p>{label ('empty')}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default CountsSummaryResults
|
export default CountsSummaryResults
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
@apply bg-slate-900 text-slate-100;
|
@apply bg-slate-900 text-slate-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .counts-grid > tbody > tr > td[data-counts-aggregate]:not([data-counts-selected]) {
|
||||||
|
background-color: #334155;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
.counts-grid > thead {
|
.counts-grid > thead {
|
||||||
@apply bg-slate-200 text-slate-950;
|
@apply bg-slate-200 text-slate-950;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { countsFieldLabel } from '@/lib/countsDefinitions'
|
import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
|
||||||
import { apiDownload, apiGet } from '@/lib/api'
|
import { apiDownload, apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { TagNameSnapshot } from '@/types'
|
import type { TagNameSnapshot } from '@/types'
|
||||||
|
|
||||||
@@ -55,7 +55,13 @@ export type CountsStateTotals = {
|
|||||||
assignments: number | null
|
assignments: number | null
|
||||||
deprecatedTags: number | null
|
deprecatedTags: number | null
|
||||||
externalAssignments: number | null }
|
externalAssignments: number | null }
|
||||||
|
export type CountsPivotHeader = { values: (string | number | null)[]
|
||||||
|
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||||
export type CountsResult = {
|
export type CountsResult = {
|
||||||
|
rowHeaders?: CountsPivotHeader[]
|
||||||
|
columnHeaders?: CountsPivotHeader[]
|
||||||
|
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
|
||||||
|
count: number }[]
|
||||||
executionAt?: string
|
executionAt?: string
|
||||||
columnValues?: (string | number | null)[][]
|
columnValues?: (string | number | null)[][]
|
||||||
cells?: { values: (string | number | null)[]; count: number }[]
|
cells?: { values: (string | number | null)[]; count: number }[]
|
||||||
@@ -83,11 +89,10 @@ export type CountRecord = Record<string, unknown>
|
|||||||
export const countColumns: Record<string, string> = {
|
export const countColumns: Record<string, string> = {
|
||||||
event_type: 'counts:eventType',
|
event_type: 'counts:eventType',
|
||||||
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
|
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
|
||||||
version_no: 'history:version', is_latest: 'counts:columns.latest',
|
version_no: 'counts:columns.version', is_latest: 'counts:columns.latest',
|
||||||
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
||||||
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
|
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
|
||||||
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
||||||
original_created_at: 'shared:labels.originalCreatedAt',
|
|
||||||
original_created_from: 'counts:columns.originalFrom',
|
original_created_from: 'counts:columns.originalFrom',
|
||||||
original_created_before: 'counts:columns.originalBefore',
|
original_created_before: 'counts:columns.originalBefore',
|
||||||
name: 'history:tagName', names: 'counts:columns.names',
|
name: 'history:tagName', names: 'counts:columns.names',
|
||||||
@@ -144,7 +149,7 @@ export const columnsForCounts = (
|
|||||||
'resource_id', 'version_no', 'is_latest',
|
'resource_id', 'version_no', 'is_latest',
|
||||||
...(resource === 'posts'
|
...(resource === 'posts'
|
||||||
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
||||||
'original_created_at', 'original_created_from', 'original_created_before']
|
'original_created_from', 'original_created_before']
|
||||||
: (resource === 'tags'
|
: (resource === 'tags'
|
||||||
? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
||||||
: (definition?.fields.filter (field => ![
|
: (definition?.fields.filter (field => ![
|
||||||
@@ -206,6 +211,7 @@ export const selectedCountColumns = (
|
|||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): CountColumn[] => {
|
): CountColumn[] => {
|
||||||
|
const resolved = normaliseCountsParams (resource, params, definition)
|
||||||
const common = ['resource_id', 'version_no', 'event_type', 'user', 'created_at']
|
const common = ['resource_id', 'version_no', 'event_type', 'user', 'created_at']
|
||||||
const defaults: Record<CountsResource, string[]> = {
|
const defaults: Record<CountsResource, string[]> = {
|
||||||
posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
|
posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
|
||||||
@@ -214,9 +220,9 @@ export const selectedCountColumns = (
|
|||||||
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
||||||
tag_groups: ['name', 'description', 'members'],
|
tag_groups: ['name', 'description', 'members'],
|
||||||
materials: ['url', 'source_kind', 'tag_id'] }
|
materials: ['url', 'source_kind', 'tag_id'] }
|
||||||
const selected = params.has ('columns') ? params.get ('columns')!.split (',')
|
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (',')
|
||||||
: [...common, ...defaults[resource]]
|
: [...common, ...defaults[resource]]
|
||||||
return orderedCountFields (fieldsForCounts (resource, definition), params)
|
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
|
||||||
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +287,7 @@ export const defaultCountsMode = (definition: string | null): 'summary' | 'detai
|
|||||||
export const normaliseCountsParams = (
|
export const normaliseCountsParams = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
source: URLSearchParams,
|
source: URLSearchParams,
|
||||||
|
definition?: CountsDefinition,
|
||||||
): URLSearchParams => {
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
if (!params.has ('definition'))
|
if (!params.has ('definition'))
|
||||||
@@ -306,6 +313,27 @@ export const normaliseCountsParams = (
|
|||||||
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
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 (','))
|
||||||
|
if (params.has ('order'))
|
||||||
|
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 ?? [])
|
||||||
|
{
|
||||||
|
if (params.has (key)) continue
|
||||||
|
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]
|
||||||
|
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|
||||||
|
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
|
||||||
|
}
|
||||||
|
params.set (key, value)
|
||||||
|
}
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type CountsClipboard = { text: string; html: string }
|
|||||||
export type CountsClipboardColumn = { label: string; children?: CountsClipboardColumn[] }
|
export type CountsClipboardColumn = { label: string; children?: CountsClipboardColumn[] }
|
||||||
export type CountsClipboardCell = string | CountsClipboardTable
|
export type CountsClipboardCell = string | CountsClipboardTable
|
||||||
export type CountsClipboardTable = {
|
export type CountsClipboardTable = {
|
||||||
columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][] }
|
columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][]; headerRows?: string[][] }
|
||||||
|
|
||||||
|
|
||||||
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||||
@@ -59,7 +59,8 @@ export const countsClipboard = (
|
|||||||
source: string[][] | CountsClipboardTable,
|
source: string[][] | CountsClipboardTable,
|
||||||
headers = false,
|
headers = false,
|
||||||
): CountsClipboard => {
|
): CountsClipboard => {
|
||||||
const headings = !Array.isArray (source) && headers ? headerRows (source.columns) : []
|
const headings = (!Array.isArray (source) && headers)
|
||||||
|
? source.headerRows ?? headerRows (source.columns) : []
|
||||||
const rows = Array.isArray (source) ? source : [...headings, ...dataRows (source)]
|
const rows = Array.isArray (source) ? source : [...headings, ...dataRows (source)]
|
||||||
const headingCount = Array.isArray (source) ? (headers ? 1 : 0) : headings.length
|
const headingCount = Array.isArray (source) ? (headers ? 1 : 0) : headings.length
|
||||||
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
|
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type CountsFieldMetadata = {
|
|||||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[] }
|
key: string; type: CountsFieldType; nullable: boolean; operators: string[] }
|
||||||
export type CountsDefinition = {
|
export type CountsDefinition = {
|
||||||
fields: CountsFieldMetadata[]
|
fields: CountsFieldMetadata[]
|
||||||
|
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
|
||||||
axes: { latest: string[]; history: string[] }
|
axes: { latest: string[]; history: string[] }
|
||||||
grains: string[] }
|
grains: string[] }
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export const countsFilterFields = (
|
|||||||
|
|
||||||
export const countsFieldLabel = (key: string): string => {
|
export const countsFieldLabel = (key: string): string => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
resource_id: 'columns.resourceId', version_no: 'history:version',
|
resource_id: 'columns.resourceId', version_no: 'columns.version',
|
||||||
user: 'history:actor', created_at: 'columns.changedAt', name: 'history:name',
|
user: 'history:actor', created_at: 'columns.changedAt', name: 'history:name',
|
||||||
title: 'columns.postTitle', url: 'columns.postURL', thumbnail_base: 'columns.thumbnailURL',
|
title: 'columns.postTitle', url: 'columns.postURL', thumbnail_base: 'columns.thumbnailURL',
|
||||||
tags: 'columns.tags', tags_json: 'columns.tags', names: 'columns.names',
|
tags: 'columns.tags', tags_json: 'columns.tags', names: 'columns.names',
|
||||||
@@ -62,11 +63,10 @@ export const countsAxisLabel = (
|
|||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): { text: string; language: string } => {
|
): { text: string; language: string } => {
|
||||||
const field = translateUIMessage (countsFieldLabel (axis.field), 'counts', locale)
|
const field = translateUIMessage (countsFieldLabel (axis.field), 'counts', locale)
|
||||||
if (axis.grain == null || definition.fields.find (item => item.key === axis.field)
|
if (definition.fields.find (item => item.key === axis.field)?.type !== 'datetime') return field
|
||||||
?.type !== 'datetime') return field
|
|
||||||
return translateUIMessage ('axisWithGrain', 'counts', locale, {
|
return translateUIMessage ('axisWithGrain', 'counts', locale, {
|
||||||
field: field.text,
|
field: field.text,
|
||||||
grain: translateUIMessage (`grains.${axis.grain}`, 'counts', locale).text })
|
grain: translateUIMessage (`grains.${axis.grain ?? 'month'}`, 'counts', locale).text })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
|||||||
sortedCountChildren } from '@/lib/counts'
|
sortedCountChildren } from '@/lib/counts'
|
||||||
import { countsEventTypes } from '@/lib/countsDefinitions'
|
import { countsEventTypes } from '@/lib/countsDefinitions'
|
||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
import { localisedDateString, msToTime } from '@/lib/utils'
|
||||||
|
|
||||||
import type { CountColumn, CountField, CountRecord,
|
import type { CountColumn, CountField, CountRecord,
|
||||||
CountsResource, CountsVersion } from '@/lib/counts'
|
CountsResource, CountsVersion } from '@/lib/counts'
|
||||||
@@ -34,13 +34,6 @@ export const countsCellText = (
|
|||||||
case 'category':
|
case 'category':
|
||||||
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
|
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
|
||||||
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
|
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
|
||||||
case 'original_created_at':
|
|
||||||
if (timezone !== 'Asia/Tokyo')
|
|
||||||
return translateUIMessage ('sections.range', 'counts', locale, {
|
|
||||||
begin: row.originalCreatedFrom == null ? '' : date (row.originalCreatedFrom),
|
|
||||||
end: row.originalCreatedBefore == null ? '' : date (row.originalCreatedBefore) }).text
|
|
||||||
return originalCreatedAtString (
|
|
||||||
row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale)
|
|
||||||
case 'original_created_from':
|
case 'original_created_from':
|
||||||
case 'original_created_before':
|
case 'original_created_before':
|
||||||
{
|
{
|
||||||
@@ -314,6 +307,8 @@ export const countsExportLabels = (
|
|||||||
labels.name = translateUIMessage ('history:name', 'counts', locale).text
|
labels.name = translateUIMessage ('history:name', 'counts', locale).text
|
||||||
for (const event of countsEventTypes)
|
for (const event of countsEventTypes)
|
||||||
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
|
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
|
||||||
|
labels.subtotal = translateUIMessage ('subtotal', 'counts', locale).text
|
||||||
|
labels.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text
|
||||||
labels.bot = translateUIMessage ('bot', 'counts', locale).text
|
labels.bot = translateUIMessage ('bot', 'counts', locale).text
|
||||||
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
|
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
|
||||||
name: '{{name}}', id: '{{id}}' }).text
|
name: '{{name}}', id: '{{id}}' }).text
|
||||||
@@ -376,7 +371,7 @@ export const countsDetailClipboardTable = (
|
|||||||
? undefined : clipboardColumns (
|
? undefined : clipboardColumns (
|
||||||
field.children, field.key, params, locale) }))
|
field.children, field.key, params, locale) }))
|
||||||
if (rowStart != null)
|
if (rowStart != null)
|
||||||
columns.unshift ({ label: translateUIMessage ('rowNumbers', 'counts', locale).text,
|
columns.unshift ({ label: translateUIMessage ('rowNumberHeading', 'counts', locale).text,
|
||||||
children: undefined })
|
children: undefined })
|
||||||
return { columns, rows: rows.map ((row, index) => {
|
return { columns, rows: rows.map ((row, index) => {
|
||||||
const cells: CountsClipboardCell[] = fields.map (field => field.children == null
|
const cells: CountsClipboardCell[] = fields.map (field => field.children == null
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const MAX_COUNT_WIDTH = 2000
|
|||||||
export const defaultCountWidths: Record<string, number> = {
|
export const defaultCountWidths: Record<string, number> = {
|
||||||
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
||||||
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
||||||
video_ms: 120, original_created_at: 260, original_created_from: 240,
|
video_ms: 120, original_created_from: 240,
|
||||||
original_created_before: 240, name: 240, names: 480, category: 180,
|
original_created_before: 240, name: 240, names: 480, category: 180,
|
||||||
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
|
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
|
||||||
period: 200, tag_id: 88, id: 88, versionNo: 88, kind: 140, sections: 260,
|
period: 200, tag_id: 88, id: 88, versionNo: 88, kind: 140, sections: 260,
|
||||||
|
|||||||
@@ -42,10 +42,11 @@
|
|||||||
"empty": "該当する履歴はありません。",
|
"empty": "該当する履歴はありません。",
|
||||||
"columns": {
|
"columns": {
|
||||||
"changedAt": "操作日時",
|
"changedAt": "操作日時",
|
||||||
|
"version": "版",
|
||||||
"resourceId": "Id.",
|
"resourceId": "Id.",
|
||||||
"postTitle": "投稿タイトル",
|
"postTitle": "タイトル",
|
||||||
"postURL": "投稿 URL",
|
"postURL": "URL",
|
||||||
"tags": "記載タグ",
|
"tags": "タグ",
|
||||||
"deprecated": "廃止状態",
|
"deprecated": "廃止状態",
|
||||||
"latest": "最新フラグ",
|
"latest": "最新フラグ",
|
||||||
"thumbnailURL": "サムネール元 URL",
|
"thumbnailURL": "サムネール元 URL",
|
||||||
@@ -232,6 +233,9 @@
|
|||||||
"profile": "出力設定",
|
"profile": "出力設定",
|
||||||
"path": "出力パス"
|
"path": "出力パス"
|
||||||
},
|
},
|
||||||
|
"rowNumberHeading": "No.",
|
||||||
|
"subtotal": "小計",
|
||||||
|
"grandTotal": "綜合計",
|
||||||
"rowNumbers": "行番号",
|
"rowNumbers": "行番号",
|
||||||
"periodSeparator": "〜",
|
"periodSeparator": "〜",
|
||||||
"bot": "$t(history:bot)",
|
"bot": "$t(history:bot)",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { CountsResource } from '@/lib/counts'
|
|||||||
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const [source, setParams] = useSearchParams ()
|
const [source, setParams] = useSearchParams ()
|
||||||
const params = normaliseCountsParams (resource, source)
|
const params = normaliseCountsParams (resource, source, definition)
|
||||||
const canonical = params.toString ()
|
const canonical = params.toString ()
|
||||||
const controlParams = new URLSearchParams (params)
|
const controlParams = new URLSearchParams (params)
|
||||||
controlParams.delete ('nested_json')
|
controlParams.delete ('nested_json')
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする