このコミットが含まれているのは:
@@ -6,7 +6,8 @@ module CountsListing
|
|||||||
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
|
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
|
||||||
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
|
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
|
||||||
.merge('locale' => locale.code))
|
.merge('locale' => locale.code))
|
||||||
result = { count: query.count, page: query.page, limit: query.limit }
|
result = { count: query.count, page: query.page, limit: query.limit,
|
||||||
|
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
|
||||||
if query.mode == 'summary'
|
if query.mode == 'summary'
|
||||||
result.merge!(query.summary)
|
result.merge!(query.summary)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CountsExportsController < ApplicationController
|
||||||
|
wrap_parameters false
|
||||||
|
|
||||||
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
|
exporter = CountsExporter.new(request.request_parameters.merge(request.query_parameters),
|
||||||
|
language_code: locale.language_code)
|
||||||
|
response.headers['Content-Type'] = exporter.format == 'json' ?
|
||||||
|
'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
|
||||||
|
response.headers['Content-Disposition'] =
|
||||||
|
"attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\""
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
# Rack::ETag otherwise materialises the response to calculate a digest.
|
||||||
|
response.headers['Last-Modified'] = Time.current.httpdate
|
||||||
|
self.response_body = exporter.body
|
||||||
|
rescue ArgumentError, TypeError, JSON::ParserError
|
||||||
|
head :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -38,13 +38,7 @@ class PostVersionsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def version_scope
|
def version_scope
|
||||||
PostVersion.joins(<<~SQL.squish)
|
PostVersion.with_previous_version
|
||||||
LEFT JOIN
|
|
||||||
post_versions prev
|
|
||||||
ON
|
|
||||||
prev.post_id = post_versions.post_id
|
|
||||||
AND prev.version_no = post_versions.version_no - 1
|
|
||||||
SQL
|
|
||||||
.select('post_versions.*',
|
.select('post_versions.*',
|
||||||
'prev.title AS prev_title',
|
'prev.title AS prev_title',
|
||||||
'prev.url AS prev_url',
|
'prev.url AS prev_url',
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
class PostVersion < ApplicationRecord
|
class PostVersion < ApplicationRecord
|
||||||
include VersionRecord
|
include VersionRecord
|
||||||
|
|
||||||
|
scope :with_previous_version, -> {
|
||||||
|
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
|
||||||
|
'AND prev.version_no = post_versions.version_no - 1')
|
||||||
|
}
|
||||||
|
|
||||||
belongs_to :post
|
belongs_to :post
|
||||||
belongs_to :parent, class_name: 'Post', optional: true
|
belongs_to :parent, class_name: 'Post', optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# Counts uses recorded values only; history/diff endpoints retain their own contract.
|
# Counts uses recorded values only; history/diff endpoints retain their own contract.
|
||||||
class CountsVersionRepr
|
class CountsVersionRepr
|
||||||
def self.many(rows, resource:, language_code:)
|
def self.many(rows, resource:, language_code:, at: nil, inclusive: true)
|
||||||
new(rows.to_a, resource:, language_code:).serialise
|
new(rows.to_a, resource:, language_code:, at:, inclusive:).serialise
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(rows, resource:, language_code:)
|
def initialize(rows, resource:, language_code:, at:, inclusive:)
|
||||||
@rows = rows
|
@rows = rows
|
||||||
|
@at = at
|
||||||
|
@inclusive = inclusive
|
||||||
@resource = resource
|
@resource = resource
|
||||||
@language = language_code
|
@language = language_code
|
||||||
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
|
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
|
||||||
@@ -45,7 +47,11 @@ class CountsVersionRepr
|
|||||||
.uniq
|
.uniq
|
||||||
pairs.each_slice(500) do |batch|
|
pairs.each_slice(500) do |batch|
|
||||||
placeholders = batch.map { '(?, ?)' }.join(', ')
|
placeholders = batch.map { '(?, ?)' }.join(', ')
|
||||||
TagVersion.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
|
scope = TagVersion.all
|
||||||
|
if @at
|
||||||
|
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
|
||||||
|
end
|
||||||
|
scope.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
|
||||||
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
|
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
|
||||||
@tag_versions[[version.tag_id, version.version_no]] = version
|
@tag_versions[[version.tag_id, version.version_no]] = version
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
require 'set'
|
||||||
|
|
||||||
|
# Export identifiers are stable; UI translations are supplied separately as column labels.
|
||||||
|
class CountsExportProjection
|
||||||
|
ROOT_KEYS = { 'resource_id' => 'resource_id', 'version_no' => 'version_no',
|
||||||
|
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
|
||||||
|
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
|
||||||
|
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
|
||||||
|
'original_created_at' => 'original_created_at',
|
||||||
|
'original_created_from' => 'original_created_from',
|
||||||
|
'original_created_before' => 'original_created_before',
|
||||||
|
'user' => 'created_by_user', 'created_at' => 'created_at',
|
||||||
|
'name' => 'name', 'names' => 'names', 'category' => 'category',
|
||||||
|
'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze
|
||||||
|
CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind',
|
||||||
|
'name' => 'name', 'category' => 'category', 'sections' => 'sections',
|
||||||
|
'names' => 'names', 'aliases' => 'aliases',
|
||||||
|
'languageCode' => 'language', 'scriptCode' => 'script',
|
||||||
|
'autoGenerated' => 'auto_generated' }.freeze
|
||||||
|
NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze
|
||||||
|
CHILDREN = {
|
||||||
|
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
|
||||||
|
'names' => NAME_FIELDS,
|
||||||
|
'aliases' => ['languageCode', 'name', 'scriptCode'],
|
||||||
|
'parents' => ['id'] }.freeze
|
||||||
|
|
||||||
|
attr_reader :columns, :headers
|
||||||
|
|
||||||
|
def initialize(params, resource:, summary:, tags:)
|
||||||
|
@params = params
|
||||||
|
@labels = JSON.parse(params.fetch('labels', '{}'))
|
||||||
|
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
|
||||||
|
raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) }
|
||||||
|
@summary = summary
|
||||||
|
allowed = if summary
|
||||||
|
tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count']
|
||||||
|
else
|
||||||
|
CountsQuery::COLUMNS.fetch(resource)
|
||||||
|
end
|
||||||
|
@columns = params.fetch('columns', allowed.join(',')).split(',')
|
||||||
|
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
|
||||||
|
|
||||||
|
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
|
||||||
|
unless (@headers.is_a?(Array) && @headers.length == @columns.length &&
|
||||||
|
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
@order = params.fetch('order', '').split(',')
|
||||||
|
@sorts = params.fetch('child_sort', '').split(',').map do |entry|
|
||||||
|
parts = entry.split(':', -1)
|
||||||
|
raise ArgumentError unless parts.length == 2
|
||||||
|
|
||||||
|
path, direction = parts
|
||||||
|
raise ArgumentError unless ['asc', 'desc'].include?(direction)
|
||||||
|
|
||||||
|
[path, direction]
|
||||||
|
end
|
||||||
|
@selected = params.key?('children') ? params['children'].split(',').to_set : nil
|
||||||
|
valid = CHILDREN.flat_map do |key, fields|
|
||||||
|
paths(fields, key)
|
||||||
|
end
|
||||||
|
raise ArgumentError if @selected && (@selected.to_a - valid).any?
|
||||||
|
allowed_sorts = valid.map do |path|
|
||||||
|
parts = path.split('.')
|
||||||
|
field = parts.pop
|
||||||
|
"#{ parts.join('.') }/#{ field }"
|
||||||
|
end
|
||||||
|
if ((@sorts.map(&:first) - allowed_sorts).any? ||
|
||||||
|
@sorts.map(&:first).uniq.length != @sorts.length)
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata
|
||||||
|
@columns.each_with_index.map do |key, index|
|
||||||
|
{ key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def project(row)
|
||||||
|
@columns.to_h do |key|
|
||||||
|
if @summary
|
||||||
|
value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym]
|
||||||
|
[key, value]
|
||||||
|
else
|
||||||
|
value = case key
|
||||||
|
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]
|
||||||
|
end
|
||||||
|
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
|
||||||
|
[ROOT_KEYS.fetch(key), value]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_record(record, row)
|
||||||
|
record.map do |key, value|
|
||||||
|
key == 'group' ? row[:name] || row[:key] : csv_display(value, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def valid_label?(key, value)
|
||||||
|
key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_display(value, key)
|
||||||
|
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
|
||||||
|
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
|
||||||
|
return @labels.fetch("category:#{ value }", value) if key == 'category'
|
||||||
|
if ['language', 'script'].include?(key) && value.is_a?(Hash)
|
||||||
|
return @labels.fetch("#{ key }:#{ value[:code] }",
|
||||||
|
value[:name] || @labels.fetch('notRegistered', ''))
|
||||||
|
end
|
||||||
|
return @labels.fetch("boolean:#{ value }", value) if value == true || value == false
|
||||||
|
return value.map { csv_display(_1, key) } if value.is_a?(Array)
|
||||||
|
if value.is_a?(Hash)
|
||||||
|
return value.to_h do |field, item|
|
||||||
|
[@labels.fetch(field.to_s, field), csv_display(item, field.to_s)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def fields_for(key)
|
||||||
|
return NAME_FIELDS if key == 'names'
|
||||||
|
return ['name', 'scriptCode'] if key == 'aliases'
|
||||||
|
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def paths(fields, prefix)
|
||||||
|
fields.flat_map do |key|
|
||||||
|
path = "#{ prefix }.#{ key }"
|
||||||
|
[path, *paths(fields_for(key), path)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym]
|
||||||
|
|
||||||
|
def named_code(row, kind)
|
||||||
|
code = fetch(row, "#{ kind }_code")
|
||||||
|
name = fetch(row, "#{ kind }_name")
|
||||||
|
{ code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
def stable(value)
|
||||||
|
case value
|
||||||
|
when Hash
|
||||||
|
value.stringify_keys.sort.to_h.transform_values { stable(_1) }
|
||||||
|
when Array
|
||||||
|
value.map { stable(_1) }
|
||||||
|
else
|
||||||
|
value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_value(row, key)
|
||||||
|
if key == 'languageCode'
|
||||||
|
language = named_code(row, 'language')
|
||||||
|
return @labels.fetch("language:#{ language[:code] }", language[:name])
|
||||||
|
end
|
||||||
|
return named_code(row, 'script')[:name] if key == 'scriptCode'
|
||||||
|
|
||||||
|
fetch(row, CHILD_KEYS.fetch(key))
|
||||||
|
end
|
||||||
|
|
||||||
|
def comparison(left, right)
|
||||||
|
if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length
|
||||||
|
return left.length <=> right.length
|
||||||
|
end
|
||||||
|
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
|
||||||
|
if [true, false].include?(left) && [true, false].include?(right)
|
||||||
|
return (left ? 1 : 0) <=> (right ? 1 : 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s
|
||||||
|
b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s
|
||||||
|
a <=> b
|
||||||
|
end
|
||||||
|
|
||||||
|
def sorted(rows, prefix, fields)
|
||||||
|
sorts = @sorts.filter_map do |path, direction|
|
||||||
|
parent, key = path.split('/', 2)
|
||||||
|
next unless parent == prefix
|
||||||
|
raise ArgumentError unless fields.include?(key)
|
||||||
|
|
||||||
|
[key, direction]
|
||||||
|
end
|
||||||
|
rows.each_with_index.sort do |(left, a), (right, b)|
|
||||||
|
result = 0
|
||||||
|
sorts.each do |key, direction|
|
||||||
|
first = sort_value(left, key)
|
||||||
|
last = sort_value(right, key)
|
||||||
|
result = if first.nil? || last.nil?
|
||||||
|
first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1)
|
||||||
|
else
|
||||||
|
comparison(first, last) * (direction == 'asc' ? 1 : -1)
|
||||||
|
end
|
||||||
|
break unless result.zero?
|
||||||
|
end
|
||||||
|
result.zero? ? a <=> b : result
|
||||||
|
end.map(&:first)
|
||||||
|
end
|
||||||
|
|
||||||
|
def nested(rows, fields, prefix)
|
||||||
|
keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") }
|
||||||
|
ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq
|
||||||
|
.filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } }
|
||||||
|
sorted(rows, prefix, fields).map do |row|
|
||||||
|
ordered.to_h do |key|
|
||||||
|
stable = CHILD_KEYS.fetch(key)
|
||||||
|
value = case key
|
||||||
|
when 'languageCode' then named_code(row, 'language')
|
||||||
|
when 'scriptCode' then named_code(row, 'script')
|
||||||
|
else fetch(row, stable)
|
||||||
|
end
|
||||||
|
if fields_for(key).any?
|
||||||
|
value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }")
|
||||||
|
end
|
||||||
|
[stable, value]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
require 'csv'
|
||||||
|
|
||||||
|
# One read view and a fixed point for the entire download; only one page is held in memory.
|
||||||
|
class CountsExporter
|
||||||
|
EXTRA_KEYS = ['basis', 'format', 'resource', 'headers', 'children', 'order',
|
||||||
|
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
|
||||||
|
attr_reader :format, :resource
|
||||||
|
|
||||||
|
def initialize(params, language_code:)
|
||||||
|
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
|
||||||
|
raise ArgumentError if (params.keys - allowed).any?
|
||||||
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
|
@params = params
|
||||||
|
@language = language_code
|
||||||
|
@format = params.fetch('format', 'json')
|
||||||
|
@resource = params.fetch('resource', 'posts')
|
||||||
|
@mode = params.fetch('mode', 'summary')
|
||||||
|
@history = params.fetch('basis', 'state') == 'history'
|
||||||
|
raise ArgumentError unless ['json', 'csv'].include?(format)
|
||||||
|
raise ArgumentError unless ['posts', 'tags'].include?(resource)
|
||||||
|
raise ArgumentError unless ['summary', 'details'].include?(@mode)
|
||||||
|
raise ArgumentError unless ['state', 'history'].include?(params.fetch('basis', 'state'))
|
||||||
|
|
||||||
|
@query_params = params.except(*EXTRA_KEYS, 'columns', 'page', 'limit')
|
||||||
|
.merge('mode' => @mode, 'page' => '1', 'limit' => '100')
|
||||||
|
point = CountsStateQuery.new(@query_params.slice('at').merge('resource' => resource),
|
||||||
|
language_code: @language).at
|
||||||
|
@query_params['at'] = point.iso8601(6)
|
||||||
|
@query_params['axis'] ||= !@history && resource == 'posts' ? 'tags' : 'month'
|
||||||
|
@projection = CountsExportProjection.new(params, resource:,
|
||||||
|
summary: @mode == 'summary',
|
||||||
|
tags: !@history && @query_params['axis'] == 'tags')
|
||||||
|
if @history
|
||||||
|
@query_params.delete('kind')
|
||||||
|
history_query
|
||||||
|
else
|
||||||
|
@query_params.delete('latest')
|
||||||
|
@state_query = CountsStateQuery.new(@query_params.merge('resource' => resource),
|
||||||
|
language_code: @language).validate_query!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def body
|
||||||
|
Enumerator.new do |output|
|
||||||
|
ApplicationRecord.transaction(isolation: :repeatable_read) do
|
||||||
|
first_row = true
|
||||||
|
started = false
|
||||||
|
pages do |page|
|
||||||
|
unless started
|
||||||
|
meta = { resource:, mode: @mode, basis: @history ? 'history' : 'state',
|
||||||
|
at: @query_params['at'], boundary: 'inclusive',
|
||||||
|
filters: @query_params.except('sort', 'page', 'limit', 'mode'),
|
||||||
|
sort: export_sort,
|
||||||
|
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
||||||
|
columns: @projection.metadata,
|
||||||
|
children: @params['children']&.split(','),
|
||||||
|
field_order: @params.fetch('order', '').split(','), timezone: 'UTC',
|
||||||
|
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
||||||
|
totals: page[:totals], coverage: page[:coverage] }
|
||||||
|
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
||||||
|
if format == 'csv'
|
||||||
|
output << CSV.generate_line(
|
||||||
|
@projection.headers.map { csv_value(_1) }, row_sep: "\r\n")
|
||||||
|
end
|
||||||
|
started = true
|
||||||
|
end
|
||||||
|
(page[:versions] || page[:state_rows] || page[:groups] || []).each do |row|
|
||||||
|
record = @projection.project(row)
|
||||||
|
if format == 'json'
|
||||||
|
output << ',' unless first_row
|
||||||
|
output << record.to_json
|
||||||
|
else
|
||||||
|
values = @projection.csv_record(record, row).map { csv_value(_1) }
|
||||||
|
output << CSV.generate_line(values, row_sep: "\r\n")
|
||||||
|
end
|
||||||
|
first_row = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
output << ']}' if format == 'json'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def export_sort
|
||||||
|
sort_metadata(@query_params.fetch('sort', default_sort))
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_metadata(value)
|
||||||
|
value.split(',').map do |entry|
|
||||||
|
key, direction = entry.split(':', 2)
|
||||||
|
{ key:, direction: }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_sort
|
||||||
|
return 'created_at:desc' if @mode == 'details'
|
||||||
|
|
||||||
|
!@history && @query_params['axis'] == 'tags' ? 'count:desc' : 'group:asc'
|
||||||
|
end
|
||||||
|
|
||||||
|
def history_query
|
||||||
|
scope = resource == 'posts' ? PostVersion.with_previous_version : TagVersion.all
|
||||||
|
CountsQuery.new(scope, resource, @query_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pages
|
||||||
|
unless @history
|
||||||
|
@state_query.each_page { yield _1 }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
query = history_query
|
||||||
|
total = @mode == 'summary' ? nil : query.count
|
||||||
|
loop do
|
||||||
|
result = if @mode == 'summary'
|
||||||
|
query.summary(include_count: @query_params['page'] == '1')
|
||||||
|
else
|
||||||
|
{ count: total,
|
||||||
|
versions: CountsVersionRepr.many(query.details, resource:,
|
||||||
|
language_code: @language,
|
||||||
|
at: Time.iso8601(@query_params['at'])) }
|
||||||
|
end
|
||||||
|
total ||= result[:group_count]
|
||||||
|
yield result
|
||||||
|
break if @query_params['page'].to_i * 100 >= total
|
||||||
|
|
||||||
|
@query_params['page'] = (@query_params['page'].to_i + 1).to_s
|
||||||
|
query = history_query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def csv_value(value)
|
||||||
|
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
||||||
|
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@ class CountsQuery
|
|||||||
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
||||||
KEYS = [
|
KEYS = [
|
||||||
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
|
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
|
||||||
'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze
|
'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze
|
||||||
|
|
||||||
attr_reader :page, :limit, :mode, :axis
|
attr_reader :page, :limit, :mode, :axis
|
||||||
|
|
||||||
@@ -41,15 +41,19 @@ class CountsQuery
|
|||||||
apply_filters
|
apply_filters
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def relation = @scope
|
||||||
|
|
||||||
def count = @scope.count
|
def count = @scope.count
|
||||||
|
|
||||||
def latest_expression
|
def latest_expression
|
||||||
return @latest_expression if @latest_expression
|
return @latest_expression if @latest_expression
|
||||||
|
|
||||||
|
at = timestamp('at')
|
||||||
|
cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : ''
|
||||||
<<~SQL.squish
|
<<~SQL.squish
|
||||||
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
||||||
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
||||||
AND newer.version_no > #{ @table }.version_no)
|
AND newer.version_no > #{ @table }.version_no #{ cutoff })
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -59,7 +63,7 @@ class CountsQuery
|
|||||||
.limit(limit).offset((page - 1) * limit)
|
.limit(limit).offset((page - 1) * limit)
|
||||||
end
|
end
|
||||||
|
|
||||||
def summary
|
def summary(include_count: true)
|
||||||
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
|
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
|
||||||
"#{ @table }.created_by_user_id"
|
"#{ @table }.created_by_user_id"
|
||||||
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
|
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
|
||||||
@@ -68,13 +72,13 @@ class CountsQuery
|
|||||||
"#{ group_order } AS group_name")).to_sql
|
"#{ group_order } AS group_name")).to_sql
|
||||||
connection = @scope.klass.connection
|
connection = @scope.klass.connection
|
||||||
source = "FROM (#{ sql }) counts_groups"
|
source = "FROM (#{ sql }) counts_groups"
|
||||||
groups = connection.select_value("SELECT COUNT(*) #{ source }")
|
groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil
|
||||||
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
|
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
|
||||||
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
|
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
|
||||||
rows = connection.select_all(
|
rows = connection.select_all(
|
||||||
"SELECT group_key, group_name, record_count #{ source } " +
|
"SELECT group_key, group_name, record_count #{ source } " +
|
||||||
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
|
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
|
||||||
{ group_count: groups.to_i,
|
{ group_count: groups&.to_i,
|
||||||
groups: rows.map do |row|
|
groups: rows.map do |row|
|
||||||
{ key: row['group_key'],
|
{ key: row['group_key'],
|
||||||
name: axis == 'user' ? row['group_name'] : nil,
|
name: axis == 'user' ? row['group_name'] : nil,
|
||||||
@@ -168,18 +172,21 @@ class CountsQuery
|
|||||||
def timestamp(key)
|
def timestamp(key)
|
||||||
value = @params[key].presence
|
value = @params[key].presence
|
||||||
return nil unless value
|
return nil unless value
|
||||||
raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/)
|
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
|
||||||
|
raise ArgumentError unless value.match?(pattern)
|
||||||
|
|
||||||
Date.iso8601(value.first(10))
|
Date.iso8601(value.first(10))
|
||||||
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
|
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_filters
|
def apply_filters
|
||||||
|
at = timestamp('at')
|
||||||
first = timestamp('from')
|
first = timestamp('from')
|
||||||
last = timestamp('before')
|
last = timestamp('before')
|
||||||
raise ArgumentError if first && last && first >= last
|
raise ArgumentError if first && last && first >= last
|
||||||
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
|
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
|
||||||
|
|
||||||
|
@scope = @scope.where("#{ @table }.created_at <= ?", at) if at
|
||||||
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
|
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
|
||||||
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
|
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
|
||||||
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# State at an exclusive UTC boundary, reconstructed only from recorded snapshots.
|
# Restores one recorded version per resource before applying record filters.
|
||||||
class CountsStateQuery
|
class CountsStateQuery
|
||||||
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
|
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
|
||||||
'kind', 'tag', 'page', 'limit', 'sort'].freeze
|
'kind', 'tag', 'page', 'limit', 'sort', 'axis', 'user_id', 'resource_id'].freeze
|
||||||
MAX_PERIODS = 366
|
MAX_PERIODS = 366
|
||||||
|
|
||||||
|
attr_reader :at
|
||||||
|
|
||||||
def initialize(params, language_code:)
|
def initialize(params, language_code:)
|
||||||
raise ArgumentError unless (params.keys - KEYS).empty?
|
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
@@ -12,14 +14,21 @@ class CountsStateQuery
|
|||||||
@connection = ApplicationRecord.connection
|
@connection = ApplicationRecord.connection
|
||||||
@language = language_code
|
@language = language_code
|
||||||
@resource = params.fetch('resource', 'posts')
|
@resource = params.fetch('resource', 'posts')
|
||||||
@mode = params.fetch('mode', 'state')
|
@mode = params.fetch('mode', 'summary')
|
||||||
|
@inclusive = ['summary', 'details'].include?(@mode)
|
||||||
|
@axis = params.fetch('axis', @resource == 'posts' ? 'tags' : 'month')
|
||||||
@kind = params.fetch('kind', 'internal')
|
@kind = params.fetch('kind', 'internal')
|
||||||
@grain = params.fetch('grain', 'month')
|
@grain = params.fetch('grain', 'month')
|
||||||
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
|
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
|
||||||
raise ArgumentError unless ['state', 'trend', 'snapshot'].include?(@mode)
|
raise ArgumentError unless ['summary', 'details', 'state', 'trend', 'snapshot'].include?(@mode)
|
||||||
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
|
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
|
||||||
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
|
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
|
||||||
|
|
||||||
|
raise ArgumentError unless ['tags', 'month', 'user'].include?(@axis)
|
||||||
|
if @mode == 'summary' && @axis == 'tags' && @resource != 'posts'
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
|
||||||
@page = integer('page', 1)
|
@page = integer('page', 1)
|
||||||
@limit = integer('limit', 20)
|
@limit = integer('limit', 20)
|
||||||
@tag = integer('tag') if params['tag'].present?
|
@tag = integer('tag') if params['tag'].present?
|
||||||
@@ -28,15 +37,19 @@ class CountsStateQuery
|
|||||||
raise ArgumentError if @mode == 'trend' && !@tag
|
raise ArgumentError if @mode == 'trend' && !@tag
|
||||||
|
|
||||||
@now = Time.current.utc
|
@now = Time.current.utc
|
||||||
@at = timestamp('at') || @now.change(usec: 0)
|
@at = timestamp('at') || @now
|
||||||
raise ArgumentError if @at > @now
|
raise ArgumentError if @at > @now
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call(metadata: true)
|
||||||
|
@metadata = metadata
|
||||||
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
|
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
|
unless @history_loaded
|
||||||
@post_start = PostVersion.minimum(:created_at)
|
@post_start = PostVersion.minimum(:created_at)
|
||||||
@tag_start = TagVersion.minimum(:created_at)
|
@tag_start = TagVersion.minimum(:created_at)
|
||||||
|
@history_loaded = true
|
||||||
|
end
|
||||||
result = { page: @page, limit: @limit, at: @at.iso8601(6),
|
result = { page: @page, limit: @limit, at: @at.iso8601(6),
|
||||||
coverage: { basis: 'recorded_snapshots', exact: false,
|
coverage: { basis: 'recorded_snapshots', exact: false,
|
||||||
post_history_from: @post_start&.iso8601(6),
|
post_history_from: @post_start&.iso8601(6),
|
||||||
@@ -44,14 +57,45 @@ class CountsStateQuery
|
|||||||
case @mode
|
case @mode
|
||||||
when 'trend'
|
when 'trend'
|
||||||
result.merge(trend)
|
result.merge(trend)
|
||||||
when 'snapshot'
|
when 'summary'
|
||||||
result.merge(totals: totals).merge(details)
|
result.merge(metadata ? { totals: totals } : {}).merge(summary)
|
||||||
|
when 'details', 'snapshot'
|
||||||
|
result.merge(metadata ? { totals: totals } : {}).merge(details)
|
||||||
else
|
else
|
||||||
result.merge(totals: totals).merge(listing)
|
result.merge(metadata ? { totals: totals } : {}).merge(listing)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def each_page
|
||||||
|
return enum_for(:each_page) unless block_given?
|
||||||
|
|
||||||
|
@page = 1
|
||||||
|
first = call
|
||||||
|
yield first
|
||||||
|
total = ['summary', 'state', 'trend'].include?(@mode) ? first[:group_count] : first[:count]
|
||||||
|
while @page * @limit < total
|
||||||
|
@page += 1
|
||||||
|
yield call(metadata: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_query!
|
||||||
|
filtered_scope(@resource)
|
||||||
|
case @mode
|
||||||
|
when 'summary'
|
||||||
|
if @axis == 'tags'
|
||||||
|
order_sql({ 'name' => 'name', 'count' => 'post_count', 'tag_id' => 'tag_id',
|
||||||
|
'kind' => 'kind', 'deprecated' => 'deprecated_at' }, 'count:desc', [])
|
||||||
|
else
|
||||||
|
record_query('summary')
|
||||||
|
end
|
||||||
|
when 'details', 'snapshot'
|
||||||
|
record_query('details')
|
||||||
|
end
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def quote(value) = @connection.quote(value)
|
def quote(value) = @connection.quote(value)
|
||||||
@@ -78,10 +122,10 @@ class CountsStateQuery
|
|||||||
def latest_condition(table, row, boundary)
|
def latest_condition(table, row, boundary)
|
||||||
key = table == 'post_versions' ? 'post_id' : 'tag_id'
|
key = table == 'post_versions' ? 'post_id' : 'tag_id'
|
||||||
<<~SQL.squish
|
<<~SQL.squish
|
||||||
#{ row }.created_at < #{ boundary } AND NOT EXISTS (
|
#{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS (
|
||||||
SELECT 1 FROM #{ table } newer
|
SELECT 1 FROM #{ table } newer
|
||||||
WHERE newer.#{ key } = #{ row }.#{ key }
|
WHERE newer.#{ key } = #{ row }.#{ key }
|
||||||
AND newer.created_at < #{ boundary }
|
AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary }
|
||||||
AND newer.version_no > #{ row }.version_no)
|
AND newer.version_no > #{ row }.version_no)
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
@@ -110,7 +154,7 @@ class CountsStateQuery
|
|||||||
def ctes
|
def ctes
|
||||||
boundary = quote(@at)
|
boundary = quote(@at)
|
||||||
<<~SQL
|
<<~SQL
|
||||||
WITH post_at AS (#{ state_sql('post_versions', boundary, live: true) }),
|
WITH post_at AS (#{ filtered_scope('posts').to_sql }),
|
||||||
tag_at AS (#{ state_sql('tag_versions', boundary) }),
|
tag_at AS (#{ state_sql('tag_versions', boundary) }),
|
||||||
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
|
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
|
||||||
pairs AS (
|
pairs AS (
|
||||||
@@ -133,7 +177,7 @@ class CountsStateQuery
|
|||||||
row = @connection.select_one(<<~SQL)
|
row = @connection.select_one(<<~SQL)
|
||||||
#{ ctes }
|
#{ ctes }
|
||||||
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
|
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
|
||||||
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard') AS tags,
|
(SELECT COUNT(*) FROM (#{ filtered_scope('tags').to_sql }) counted_tags) AS tags,
|
||||||
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
|
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
|
||||||
AND deprecated_at IS NOT NULL) AS deprecated_tags,
|
AND deprecated_at IS NOT NULL) AS deprecated_tags,
|
||||||
(SELECT COUNT(*) FROM pairs) AS assignments,
|
(SELECT COUNT(*) FROM pairs) AS assignments,
|
||||||
@@ -148,7 +192,9 @@ class CountsStateQuery
|
|||||||
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
|
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def available?(first, boundary) = first && first < boundary
|
def available?(first, boundary)
|
||||||
|
first && (@inclusive ? first <= boundary : first < boundary)
|
||||||
|
end
|
||||||
|
|
||||||
def listing
|
def listing
|
||||||
path = quote("$.#{ JSON.generate(@language) }.name")
|
path = quote("$.#{ JSON.generate(@language) }.name")
|
||||||
@@ -176,7 +222,9 @@ class CountsStateQuery
|
|||||||
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
|
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
|
||||||
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
|
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
|
||||||
prefix = "#{ ctes } #{ catalog }"
|
prefix = "#{ ctes } #{ catalog }"
|
||||||
total = @connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
|
total = if @metadata
|
||||||
|
@connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
|
||||||
|
end
|
||||||
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
|
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
|
||||||
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
|
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
|
||||||
'deprecated' => 'c.deprecated_at' }, 'count:desc',
|
'deprecated' => 'c.deprecated_at' }, 'count:desc',
|
||||||
@@ -195,13 +243,14 @@ class CountsStateQuery
|
|||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
|
|
||||||
def details
|
# All record filters apply after selecting the latest recorded version at the point.
|
||||||
table = @resource == 'posts' ? 'post_versions' : 'tag_versions'
|
def restored_scope(resource)
|
||||||
model = @resource == 'posts' ? PostVersion : TagVersion
|
model = resource == 'posts' ? PostVersion : TagVersion
|
||||||
scope = model.joins("INNER JOIN (#{ state_sql(table, quote(@at), live: true) }) " +
|
table = model.table_name
|
||||||
"counts_at ON counts_at.id = #{ table }.id")
|
scope = model.where(Arel.sql(latest_condition(table, table, quote(@at))))
|
||||||
|
.where.not(event_type: 'discard')
|
||||||
if @tag
|
if @tag
|
||||||
scope = if @resource == 'posts'
|
scope = if resource == 'posts'
|
||||||
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
|
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
|
||||||
elsif @kind == 'internal'
|
elsif @kind == 'internal'
|
||||||
scope.where(tag_id: @tag)
|
scope.where(tag_id: @tag)
|
||||||
@@ -209,12 +258,36 @@ class CountsStateQuery
|
|||||||
scope.none
|
scope.none
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_scope(resource)
|
||||||
|
params = @params.slice('from', 'before', 'user_id', 'resource_id')
|
||||||
|
.merge('mode' => 'details', 'latest' => '0', 'sort' => '')
|
||||||
|
CountsQuery.new(restored_scope(resource), resource, params,
|
||||||
|
latest_expression: '1').relation
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_query(mode)
|
||||||
params = @params.slice('locale', 'sort', 'page', 'limit')
|
params = @params.slice('locale', 'sort', 'page', 'limit')
|
||||||
.merge('mode' => 'details', 'latest' => '0')
|
.merge('mode' => mode, 'latest' => '0', 'axis' => @axis == 'tags' ? 'month' : @axis,
|
||||||
query = CountsQuery.new(scope, @resource, params, latest_expression: '1')
|
'page' => @page.to_s, 'limit' => @limit.to_s)
|
||||||
{ count: query.count,
|
CountsQuery.new(filtered_scope(@resource), @resource, params, latest_expression: '1')
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
return listing if @axis == 'tags'
|
||||||
|
|
||||||
|
query = record_query('summary')
|
||||||
|
{ count: @metadata ? query.count : nil }.merge(query.summary(include_count: @metadata))
|
||||||
|
end
|
||||||
|
|
||||||
|
def details
|
||||||
|
query = record_query('details')
|
||||||
|
{ count: @metadata ? query.count : nil,
|
||||||
versions: CountsVersionRepr.many(query.details, resource: @resource,
|
versions: CountsVersionRepr.many(query.details, resource: @resource,
|
||||||
language_code: @language) }
|
language_code: @language, at: @at,
|
||||||
|
inclusive: @inclusive) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_boundary(time)
|
def next_boundary(time)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
get 'counts/export', to: 'counts_exports#index'
|
||||||
|
post 'counts/export', to: 'counts_exports#index'
|
||||||
get 'counts/state', to: 'counts_states#index'
|
get 'counts/state', to: 'counts_states#index'
|
||||||
get 'counts/posts', to: 'counts_posts#index'
|
get 'counts/posts', to: 'counts_posts#index'
|
||||||
get 'counts/tags', to: 'counts_tags#index'
|
get 'counts/tags', to: 'counts_tags#index'
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import { useEffect, useId, useState } from 'react'
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
|
||||||
import CountsStateFilters from '@/components/counts/CountsStateFilters'
|
|
||||||
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
|
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
|
||||||
|
import { countsSummaryFields } from '@/lib/countsState'
|
||||||
import { fetchTagByName } from '@/lib/tags'
|
import { fetchTagByName } from '@/lib/tags'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } 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 { ReactNode } from 'react'
|
||||||
|
|
||||||
import type { CountsResource } from '@/lib/counts'
|
import type { CountsResource } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
const CountsControls = (
|
const CountsControls = (
|
||||||
{ resource, applied, open, setOpen, execute, displayJSON, reorder }: {
|
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
|
||||||
resource: CountsResource
|
resource: CountsResource
|
||||||
applied: URLSearchParams
|
applied: URLSearchParams
|
||||||
open: boolean
|
open: boolean
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
execute: (params: URLSearchParams) => void
|
execute: (params: URLSearchParams) => void
|
||||||
displayJSON: (enabled: boolean) => void
|
displayJSON: (enabled: boolean) => void
|
||||||
reorder: (value: string) => void },
|
reorder: (value: string) => void
|
||||||
|
actions?: ReactNode },
|
||||||
) => {
|
) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
@@ -43,13 +46,18 @@ const CountsControls = (
|
|||||||
}, [order, nestedJSON])
|
}, [order, nestedJSON])
|
||||||
const [error, setError] = useState (false)
|
const [error, setError] = useState (false)
|
||||||
const [pending, setPending] = useState (false)
|
const [pending, setPending] = useState (false)
|
||||||
const mode = draft.get ('mode') ?? 'state'
|
const mode = draft.get ('mode') ?? 'summary'
|
||||||
const state = isCountsState (draft)
|
const state = isCountsState (draft)
|
||||||
|
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
|
||||||
const update = (key: string, value: string) => {
|
const update = (key: string, value: string) => {
|
||||||
setDraft (previous => {
|
setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
if (key === 'mode') next.delete ('sort')
|
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
|
||||||
if (value || key === 'columns' || key === 'children') next.set (key, value)
|
if (key === 'axis' || key === 'basis') next.delete ('summary_columns')
|
||||||
|
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags')
|
||||||
|
next.set ('axis', 'month')
|
||||||
|
if (value || ['columns', 'children', 'summary_columns'].includes (key))
|
||||||
|
next.set (key, value)
|
||||||
else next.delete (key)
|
else next.delete (key)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -60,7 +68,7 @@ const CountsControls = (
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const next = new URLSearchParams (draft)
|
const next = new URLSearchParams (draft)
|
||||||
for (const key of (state || resource === 'posts') ? ['tag'] : ['resource_id'])
|
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
|
||||||
{
|
{
|
||||||
const value = next.get (key)?.trim () ?? ''
|
const value = next.get (key)?.trim () ?? ''
|
||||||
if (value && !/^[1-9][0-9]*$/.test (value))
|
if (value && !/^[1-9][0-9]*$/.test (value))
|
||||||
@@ -99,14 +107,16 @@ const CountsControls = (
|
|||||||
<select className={inputClass (false, 'w-auto py-1')}
|
<select className={inputClass (false, 'w-auto py-1')}
|
||||||
aria-label={message ('mode').text} lang={message ('mode').language}
|
aria-label={message ('mode').text} lang={message ('mode').language}
|
||||||
value={mode} onChange={event => update ('mode', event.target.value)}>
|
value={mode} onChange={event => update ('mode', event.target.value)}>
|
||||||
{[['state', 'summary'], ['trend', 'state.trend'], ['snapshot', 'state.details'],
|
{['summary', 'details'].map (value => (
|
||||||
['summary', 'state.history'], ['details', 'state.historyDetails']].map (
|
<option key={value} value={value} lang={message (value).language}>
|
||||||
([value, key]) => <option key={value} value={value} lang={message (key).language}>
|
{message (value).text}</option>))}
|
||||||
{message (key).text}</option>)}
|
|
||||||
</select>
|
</select>
|
||||||
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
||||||
<div id={id} hidden={!open} className="border-b py-2 dark:border-slate-700">
|
<div id={id} hidden={!open}
|
||||||
|
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2
|
||||||
|
dark:border-slate-700 md:max-h-[45dvh]">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<fieldset className="min-w-0" data-counts-output>
|
<fieldset className="min-w-0" data-counts-output>
|
||||||
<legend className="mb-2 font-semibold">{label ('output')}</legend>
|
<legend className="mb-2 font-semibold">{label ('output')}</legend>
|
||||||
@@ -118,33 +128,59 @@ const CountsControls = (
|
|||||||
}}/>
|
}}/>
|
||||||
{label ('nested.asJSON')}
|
{label ('nested.asJSON')}
|
||||||
</label>
|
</label>
|
||||||
{(mode === 'state' || mode === 'trend') ? (
|
<FormField label={label ('measure')}>
|
||||||
<p className="text-sm">{label (
|
{() => <select value={state ? 'state' : 'history'}
|
||||||
mode === 'trend' ? 'state.trend' : 'state.list')}</p>) : (mode === 'summary' ? (
|
className={inputClass (false, 'w-auto py-1')}
|
||||||
<label className="flex flex-wrap items-center gap-2">
|
aria-label={message ('measure').text} lang={message ('measure').language}
|
||||||
|
onChange={event => update ('basis', event.target.value)}>
|
||||||
|
<option value="state" lang={message (`state.totals.${resource}`).language}>
|
||||||
|
{message (`state.totals.${resource}`).text}</option>
|
||||||
|
<option value="history" lang={message ('metric').language}>
|
||||||
|
{message ('metric').text}</option>
|
||||||
|
</select>}
|
||||||
|
</FormField>
|
||||||
|
{mode === 'summary' && <label className="my-2 flex flex-wrap items-center gap-2">
|
||||||
{label ('axis')}
|
{label ('axis')}
|
||||||
<select className={inputClass (false, 'w-auto py-1')}
|
<select className={inputClass (false, 'w-auto py-1')}
|
||||||
value={draft.get ('axis') ?? 'month'}
|
value={draft.get ('axis')
|
||||||
|
?? ((state && resource === 'posts') ? 'tags' : 'month')}
|
||||||
aria-label={message ('axis').text} lang={message ('axis').language}
|
aria-label={message ('axis').text} lang={message ('axis').language}
|
||||||
onChange={event => update ('axis', event.target.value)}>
|
onChange={event => update ('axis', event.target.value)}>
|
||||||
{['month', 'user'].map (axis => (
|
{axes.map (axis => <option key={axis} value={axis}
|
||||||
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}>
|
lang={message (`axes.${axis}`).language}>
|
||||||
{message (`axes.${axis}`).text}</option>))}
|
{message (`axes.${axis}`).text}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>) : (
|
</label>}
|
||||||
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
|
<CountsFieldTree resource={resource} params={draft}
|
||||||
|
fields={mode === 'summary' ? countsSummaryFields (resource, draft) : undefined}
|
||||||
|
selectionKey={mode === 'summary' ? 'summary_columns' : 'columns'}
|
||||||
|
parent={mode === 'summary' ? 'summary' : ''} update={(key, value) => {
|
||||||
update (key, value)
|
update (key, value)
|
||||||
if (key === 'order') reorder (value)
|
if (key === 'order') reorder (value)
|
||||||
}}/>))}
|
}}/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||||
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
||||||
{state ? <CountsStateFilters params={draft} update={update}/> : <>
|
<FormField label={label ('state.at')}>
|
||||||
<label className="flex items-center gap-1">
|
{() => <DateTimeField value={draft.get ('at') ?? ''}
|
||||||
|
aria-label={message ('state.at').text} lang={message ('state.at').language}
|
||||||
|
onChange={value => update ('at', value ?? '')}/>}
|
||||||
|
</FormField>
|
||||||
|
{!state && <label className="flex items-center gap-1">
|
||||||
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
||||||
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
||||||
{label ('latestOnly')}
|
{label ('latestOnly')}
|
||||||
</label>
|
</label>}
|
||||||
|
{state && resource === 'posts' && <FormField label={label ('columns.kind')}>
|
||||||
|
{() => <select value={draft.get ('kind') ?? 'internal'}
|
||||||
|
className={inputClass (false, 'w-auto py-1')}
|
||||||
|
aria-label={message ('columns.kind').text} lang={message ('columns.kind').language}
|
||||||
|
onChange={event => update ('kind', event.target.value)}>
|
||||||
|
{['internal', 'external'].map (kind => <option key={kind} value={kind}
|
||||||
|
lang={message (`kinds.${kind}`).language}>
|
||||||
|
{message (`kinds.${kind}`).text}</option>)}
|
||||||
|
</select>}
|
||||||
|
</FormField>}
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{['from', 'before'].map (key => (
|
{['from', 'before'].map (key => (
|
||||||
<FormField key={key} label={label (`fields.${key}`)}>
|
<FormField key={key} label={label (`fields.${key}`)}>
|
||||||
@@ -167,7 +203,6 @@ const CountsControls = (
|
|||||||
className={inputClass (false, 'py-1')}/>}
|
className={inputClass (false, 'py-1')}/>}
|
||||||
</FormField>))}
|
</FormField>))}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DndContext, MouseSensor, TouchSensor, closestCenter,
|
|||||||
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||||
|
|
||||||
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
|
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
|
||||||
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
@@ -36,7 +36,8 @@ const FieldBranch = (
|
|||||||
{label (field.label)}
|
{label (field.label)}
|
||||||
</label>
|
</label>
|
||||||
return (
|
return (
|
||||||
<div ref={drop.setNodeRef} className={cn ('flex items-start gap-1',
|
<div ref={drop.setNodeRef} className={cn ('max-w-full rounded border border-slate-200 px-1',
|
||||||
|
'dark:border-slate-700',
|
||||||
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
|
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
|
||||||
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
|
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
|
||||||
style={drag.transform == null ? undefined : {
|
style={drag.transform == null ? undefined : {
|
||||||
@@ -60,7 +61,7 @@ const FieldBranch = (
|
|||||||
{field.children ? (
|
{field.children ? (
|
||||||
<details className="my-1 min-w-0 flex-1">
|
<details className="my-1 min-w-0 flex-1">
|
||||||
<summary className="cursor-pointer">{control}</summary>
|
<summary className="cursor-pointer">{control}</summary>
|
||||||
<div className="ml-4 border-l pl-2 dark:border-slate-700">
|
<div className="ml-2 border-l pl-1 dark:border-slate-700">
|
||||||
<FieldGroup fields={field.children} path={path} params={params}
|
<FieldGroup fields={field.children} path={path} params={params}
|
||||||
enabled={enabled && checked} selected={selected} select={select}
|
enabled={enabled && checked} selected={selected} select={select}
|
||||||
update={update}/>
|
update={update}/>
|
||||||
@@ -99,6 +100,7 @@ const FieldGroup = (
|
|||||||
onDragEnd={({ active, over }) => {
|
onDragEnd={({ active, over }) => {
|
||||||
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
|
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
|
||||||
}}>
|
}}>
|
||||||
|
<div className="flex flex-wrap items-start gap-1">
|
||||||
{ordered.map (field => {
|
{ordered.map (field => {
|
||||||
const position = movable.findIndex (item => item.key === field.key)
|
const position = movable.findIndex (item => item.key === field.key)
|
||||||
return <FieldBranch key={field.key} field={field}
|
return <FieldBranch key={field.key} field={field}
|
||||||
@@ -107,28 +109,36 @@ const FieldGroup = (
|
|||||||
select={select} update={update} move={move}
|
select={select} update={update} move={move}
|
||||||
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
|
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</DndContext>)
|
</DndContext>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const CountsFieldTree = (
|
const CountsFieldTree = (
|
||||||
{ resource, params, update }: {
|
{ resource, params, update, fields: suppliedFields,
|
||||||
|
selectionKey = 'columns', parent = '' }: {
|
||||||
resource: CountsResource
|
resource: CountsResource
|
||||||
|
fields?: CountField[]
|
||||||
|
selectionKey?: string
|
||||||
|
parent?: string
|
||||||
params: URLSearchParams
|
params: URLSearchParams
|
||||||
update: (key: string, value: string) => void },
|
update: (key: string, value: string) => void },
|
||||||
) => {
|
) => {
|
||||||
const { label } = useUITranslation ('counts')
|
const { label } = useUITranslation ('counts')
|
||||||
const fields = fieldsForCounts (resource)
|
const fields = suppliedFields ?? fieldsForCounts (resource)
|
||||||
const roots = selectedCountColumns (resource, params)
|
const roots = suppliedFields == null ? selectedCountColumns (resource, params)
|
||||||
|
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
|
||||||
const selected = selectedCountChildren (params)
|
const selected = selectedCountChildren (params)
|
||||||
for (const root of roots) selected.add (root)
|
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
|
||||||
|
for (const root of roots) selected.add (rootPath (root))
|
||||||
const select = (path: string, checked: boolean) => {
|
const select = (path: string, checked: boolean) => {
|
||||||
if (!path.includes ('.'))
|
const root = fields.find (field => rootPath (field.key) === path)
|
||||||
|
if (root != null)
|
||||||
{
|
{
|
||||||
const next = new Set<string> (roots)
|
const next = new Set<string> (roots)
|
||||||
if (checked) next.add (path)
|
if (checked) next.add (root.key)
|
||||||
else next.delete (path)
|
else next.delete (root.key)
|
||||||
update ('columns', fields.filter (field => next.has (field.key))
|
update (selectionKey, fields.filter (field => next.has (field.key))
|
||||||
.map (field => field.key).join (','))
|
.map (field => field.key).join (','))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,9 +149,9 @@ const CountsFieldTree = (
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldGroup fields={fields} path="" params={params} enabled selected={selected}
|
<FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
|
||||||
select={select} update={update}/>
|
select={select} update={update}/>
|
||||||
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
|
{suppliedFields == null && <p className="mt-2 text-xs">{label ('nested.selection')}</p>}
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,34 +27,11 @@ const CountsResults = (
|
|||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const summary = (params.get ('mode') ?? 'summary') === 'summary'
|
|
||||||
const axis = params.get ('axis') ?? 'month'
|
|
||||||
const columns = selectedCountColumns (resource, params)
|
const columns = selectedCountColumns (resource, params)
|
||||||
const fields = fieldsForCounts (resource)
|
const fields = fieldsForCounts (resource)
|
||||||
const sorts = readCountSort (params.get ('sort') ?? (summary ? 'group:asc' : 'created_at:desc'))
|
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
||||||
const sort = (key: string, additive: boolean) =>
|
const sort = (key: string, additive: boolean) =>
|
||||||
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||||
const drillDown = (key: string | number | null): string => {
|
|
||||||
const next = new URLSearchParams (params)
|
|
||||||
next.set ('mode', 'details')
|
|
||||||
next.delete ('page')
|
|
||||||
next.delete ('sort')
|
|
||||||
if (axis === 'month')
|
|
||||||
{
|
|
||||||
const first = `${key}-01T00:00Z`
|
|
||||||
const date = new Date (first)
|
|
||||||
date.setUTCMonth (date.getUTCMonth () + 1)
|
|
||||||
const last = date.toISOString ().slice (0, 16) + 'Z'
|
|
||||||
const from = next.get ('from')
|
|
||||||
const before = next.get ('before')
|
|
||||||
next.set ('from',
|
|
||||||
(from != null && Date.parse (from) > Date.parse (first)) ? from : first)
|
|
||||||
next.set ('before',
|
|
||||||
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
|
|
||||||
}
|
|
||||||
else next.set ('user_id', String (key ?? 'none'))
|
|
||||||
return `?${next}`
|
|
||||||
}
|
|
||||||
const cell = (row: CountsVersion, column: CountColumn) => {
|
const cell = (row: CountsVersion, column: CountColumn) => {
|
||||||
const field = fields.find (field => field.key === column)!
|
const field = fields.find (field => field.key === column)!
|
||||||
const value = countsCellText (row, column, locale)
|
const value = countsCellText (row, column, locale)
|
||||||
@@ -109,20 +86,15 @@ const CountsResults = (
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||||
{!summary && columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
{columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||||
<CountsSelectableTable family={summary ? 'summary' : resource}
|
<CountsSelectableTable family={resource}
|
||||||
columns={summary ? ['group', 'count'] : columns}
|
columns={columns}
|
||||||
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
|
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
|
||||||
(data.versions ?? []).map (row => row.id)}`}
|
(data.versions ?? []).map (row => row.id)}`}
|
||||||
className="text-left text-sm">
|
className="text-left text-sm">
|
||||||
<thead className="sticky top-0 z-20 bg-background text-foreground
|
<thead className="sticky top-0 z-20 bg-background text-foreground
|
||||||
dark:bg-background dark:text-foreground"><tr>
|
dark:bg-background dark:text-foreground"><tr>
|
||||||
{summary ? <>
|
{columns.map (column => column === 'thumbnail'
|
||||||
<CountsSortHeader field="group" label={message (`axes.${axis}`)}
|
|
||||||
sorts={sorts} change={sort}/>
|
|
||||||
<CountsSortHeader field="count" label={message ('metric')}
|
|
||||||
sorts={sorts} change={sort}/>
|
|
||||||
</> : columns.map (column => column === 'thumbnail'
|
|
||||||
? <th key={column} data-counts-heading={message (countColumns[column]).text}
|
? <th key={column} data-counts-heading={message (countColumns[column]).text}
|
||||||
className="relative px-2 py-1">
|
className="relative px-2 py-1">
|
||||||
<span className="sr-only">{label (countColumns[column])}</span>
|
<span className="sr-only">{label (countColumns[column])}</span>
|
||||||
@@ -134,18 +106,7 @@ const CountsResults = (
|
|||||||
field => field.key === column)?.children != null}/>)}
|
field => field.key === column)?.children != null}/>)}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{summary ? (data.groups ?? []).map (group => (
|
{(data.versions ?? []).map (row => (
|
||||||
<tr key={String (group.key)} className="border-b dark:border-slate-700">
|
|
||||||
<td className="break-words border-b px-2 py-1 dark:border-slate-700"
|
|
||||||
data-counts-copy={group.key == null ? message (
|
|
||||||
'history:bot').text : group.name ?? String (group.key)}>
|
|
||||||
<PrefetchLink to={drillDown (group.key)}>
|
|
||||||
{group.key == null
|
|
||||||
? label ('history:bot') : group.name ?? String (group.key)}
|
|
||||||
</PrefetchLink></td>
|
|
||||||
<td className="border-b px-2 py-1 dark:border-slate-700"
|
|
||||||
data-counts-copy={String (group.count)}>{group.count}</td>
|
|
||||||
</tr>)) : (data.versions ?? []).map (row => (
|
|
||||||
<tr key={row.id} className="border-b dark:border-slate-700">
|
<tr key={row.id} className="border-b dark:border-slate-700">
|
||||||
{columns.map (column => (
|
{columns.map (column => (
|
||||||
<td key={column}
|
<td key={column}
|
||||||
@@ -158,7 +119,7 @@ const CountsResults = (
|
|||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</CountsSelectableTable>)}
|
</CountsSelectableTable>)}
|
||||||
{(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0
|
{(data.versions?.length ?? 0) === 0
|
||||||
&& <p className="p-2">{label ('empty')}</p>}
|
&& <p className="p-2">{label ('empty')}</p>}
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import DateTimeField from '@/components/common/DateTimeField'
|
|
||||||
import FormField from '@/components/common/FormField'
|
|
||||||
import { inputClass } from '@/lib/utils'
|
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
|
||||||
|
|
||||||
|
|
||||||
const CountsStateFilters = (
|
|
||||||
{ params, update }: {
|
|
||||||
params: URLSearchParams
|
|
||||||
update: (key: string, value: string) => void },
|
|
||||||
) => {
|
|
||||||
const { label, message } = useUITranslation ('counts')
|
|
||||||
const trend = params.get ('mode') === 'trend'
|
|
||||||
const dates = trend ? ['from', 'before'] : ['at']
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
|
||||||
{dates.map (key => {
|
|
||||||
const text = key === 'at' ? 'state.at' : `fields.${key}`
|
|
||||||
return <FormField key={key} label={label (text)}>
|
|
||||||
{() => <DateTimeField value={params.get (key) ?? ''}
|
|
||||||
aria-label={message (text).text} lang={message (text).language}
|
|
||||||
onChange={value => update (key, value ?? '')}/>}
|
|
||||||
</FormField>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{trend && <FormField label={label ('state.grain')}>
|
|
||||||
{() => <select value={params.get ('grain') ?? 'month'}
|
|
||||||
className={inputClass (false, 'py-1')}
|
|
||||||
aria-label={message ('state.grain').text}
|
|
||||||
onChange={event => update ('grain', event.target.value)}>
|
|
||||||
{['day', 'month', 'year'].map (grain => (
|
|
||||||
<option key={grain} value={grain} lang={message (`state.grains.${grain}`).language}>
|
|
||||||
{message (`state.grains.${grain}`).text}</option>))}
|
|
||||||
</select>}
|
|
||||||
</FormField>}
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
|
||||||
<FormField label={label ('columns.kind')}>
|
|
||||||
{() => <select value={params.get ('kind') ?? 'internal'}
|
|
||||||
className={inputClass (false, 'py-1')} aria-label={message ('columns.kind').text}
|
|
||||||
onChange={event => update ('kind', event.target.value)}>
|
|
||||||
{['internal', 'external'].map (kind => (
|
|
||||||
<option key={kind} value={kind} lang={message (`kinds.${kind}`).language}>
|
|
||||||
{message (`kinds.${kind}`).text}</option>))}
|
|
||||||
</select>}
|
|
||||||
</FormField>
|
|
||||||
<FormField label={label ('fields.tag')}>
|
|
||||||
{() => <input value={params.get ('tag') ?? ''} required={trend}
|
|
||||||
aria-label={message ('fields.tag').text} className={inputClass (false, 'py-1')}
|
|
||||||
onChange={event => update ('tag', event.target.value)}/>}
|
|
||||||
</FormField>
|
|
||||||
<FormField label={label ('fields.limit')}>
|
|
||||||
{() => <input type="number" min={1} max={100}
|
|
||||||
value={params.get ('limit') ?? '20'} aria-label={message ('fields.limit').text}
|
|
||||||
className={inputClass (false, 'py-1')}
|
|
||||||
onChange={event => update ('limit', event.target.value)}/>}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default CountsStateFilters
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import LocalisedText from '@/components/LocalisedText'
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
|
||||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
|
||||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
|
||||||
import { changeCountSort, readCountSort, writeCountSort } from '@/lib/counts'
|
|
||||||
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
|
||||||
|
|
||||||
import type { CountsResult } from '@/lib/counts'
|
|
||||||
|
|
||||||
|
|
||||||
const CountsStateResults = (
|
|
||||||
{ data, params, pending, update }: {
|
|
||||||
data: CountsResult
|
|
||||||
params: URLSearchParams
|
|
||||||
pending: boolean
|
|
||||||
update: (key: string, value: string) => void },
|
|
||||||
) => {
|
|
||||||
const locale = useLocale ()
|
|
||||||
const { label, message } = useUITranslation ('counts')
|
|
||||||
const trend = params.get ('mode') === 'trend'
|
|
||||||
const fields = countsStateFields (trend)
|
|
||||||
const sorts = readCountSort (params.get ('sort') ?? (trend ? 'period:asc' : 'count:desc'))
|
|
||||||
return (
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
|
||||||
<CountsSelectableTable family={trend ? 'state_trend' : 'state_tags'}
|
|
||||||
columns={fields.map (field => field.key)} identity={params.toString ()}
|
|
||||||
className="text-left text-sm">
|
|
||||||
<thead className="sticky top-0 z-20"><tr>
|
|
||||||
{fields.map (field => (
|
|
||||||
<CountsSortHeader key={field.key} field={field.key} label={message (field.label)}
|
|
||||||
sorts={sorts} change={(key, additive) => update (
|
|
||||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>{(data.stateRows ?? []).map (row => (
|
|
||||||
<tr key={`${row.kind}:${row.tagId}:${row.at}`}>
|
|
||||||
{fields.map (field => {
|
|
||||||
const value = countsStateCell (row, field.key, locale)
|
|
||||||
return <td key={field.key} data-counts-copy={value}
|
|
||||||
className="break-words border-b px-2 py-1 dark:border-slate-700">
|
|
||||||
{field.key === 'count' && row.count != null
|
|
||||||
? <PrefetchLink to={countsStateDrill (params, row.at, 'posts', row)}>
|
|
||||||
{value}</PrefetchLink>
|
|
||||||
: <LocalisedText language={message (field.label).language}>
|
|
||||||
{value}</LocalisedText>}
|
|
||||||
</td>
|
|
||||||
})}
|
|
||||||
</tr>))}</tbody>
|
|
||||||
</CountsSelectableTable>
|
|
||||||
{data.stateRows?.length === 0 && <p className="p-2">{label ('empty')}</p>}
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default CountsStateResults
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||||
|
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||||
|
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||||
|
import { countsSummaryCell, countsSummaryDrill, countsSummaryRows,
|
||||||
|
selectedCountsSummaryFields } from '@/lib/countsState'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountsResource, CountsResult } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsSummaryResults = (
|
||||||
|
{ resource, data, params, pending, update }: {
|
||||||
|
resource: CountsResource
|
||||||
|
data: CountsResult
|
||||||
|
params: URLSearchParams
|
||||||
|
pending: boolean
|
||||||
|
update: (key: string, value: string) => void },
|
||||||
|
) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { label, message } = useUITranslation ('counts')
|
||||||
|
const fields = selectedCountsSummaryFields (resource, params)
|
||||||
|
const rows = countsSummaryRows (data)
|
||||||
|
const sorts = readCountSort (params.get ('sort') ?? (
|
||||||
|
params.get ('axis') === 'tags' ? 'count:desc' : 'group:asc'))
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||||
|
{fields.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||||
|
<CountsSelectableTable family={`summary_${params.get ('axis')}`}
|
||||||
|
columns={fields.map (field => field.key)}
|
||||||
|
identity={`${countsRequestParams (params)}:${fields.map (field => field.key)}`}
|
||||||
|
className="text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-20"><tr>
|
||||||
|
{fields.map (field => (
|
||||||
|
<CountsSortHeader key={field.key} field={field.key}
|
||||||
|
label={message (field.label)}
|
||||||
|
sorts={sorts} change={(key, additive) => update (
|
||||||
|
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{rows.map (row => (
|
||||||
|
<tr key={String (row.key)}>
|
||||||
|
{fields.map (field => {
|
||||||
|
const value = countsSummaryCell (row, field.key, locale)
|
||||||
|
const content = value.language == null ? value.text : (
|
||||||
|
<LocalisedText language={value.language}>{value.text}</LocalisedText>)
|
||||||
|
return <td key={field.key} data-counts-copy={value.text}
|
||||||
|
className="break-words border-b px-2 py-1 dark:border-slate-700">
|
||||||
|
{(['count', 'group'].includes (field.key) && row.count != null)
|
||||||
|
? <PrefetchLink to={countsSummaryDrill (resource, params, row)}>
|
||||||
|
{content}</PrefetchLink>
|
||||||
|
: content}
|
||||||
|
</td>
|
||||||
|
})}
|
||||||
|
</tr>))}</tbody>
|
||||||
|
</CountsSelectableTable>)}
|
||||||
|
{rows.length === 0 && <p className="p-2">{label ('empty')}</p>}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsSummaryResults
|
||||||
+51
-13
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet } from '@/lib/api'
|
import { apiGet, apiPost } from '@/lib/api'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { TagNameSnapshot } from '@/types'
|
import type { TagNameSnapshot } from '@/types'
|
||||||
@@ -216,16 +216,42 @@ export const changeCountSort = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const selectedCountFields = (
|
||||||
|
fields: CountField[],
|
||||||
|
params: URLSearchParams,
|
||||||
|
selectionKey: string,
|
||||||
|
parent = '',
|
||||||
|
): CountField[] => {
|
||||||
|
const selected = params.has (selectionKey)
|
||||||
|
? new Set (params.get (selectionKey)!.split (',')) : null
|
||||||
|
return orderedCountFields (fields, params, parent)
|
||||||
|
.filter (field => selected == null || selected.has (field.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const normaliseCountsParams = (
|
export const normaliseCountsParams = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
source: URLSearchParams,
|
source: URLSearchParams,
|
||||||
): URLSearchParams => {
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
params.delete ('event_type')
|
params.delete ('event_type')
|
||||||
if (!params.has ('mode'))
|
const legacy = params.get ('mode')
|
||||||
params.set ('mode', source.has ('axis') ? 'summary' : 'state')
|
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
|
||||||
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
{
|
||||||
params.set ('axis', 'month')
|
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
|
||||||
|
if (legacy !== 'snapshot') params.set ('axis', resource === 'posts' ? 'tags' : 'month')
|
||||||
|
if (legacy === 'trend')
|
||||||
|
{
|
||||||
|
if (params.get ('before')) params.set ('at', params.get ('before')!)
|
||||||
|
params.delete ('from')
|
||||||
|
params.delete ('before')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!params.has ('mode')) params.set ('mode', 'summary')
|
||||||
|
const axes = (resource === 'posts' && params.get ('basis') !== 'history')
|
||||||
|
? ['tags', 'month', 'user'] : ['month', 'user']
|
||||||
|
if (!axes.includes (params.get ('axis') ?? '')) params.set ('axis', axes[0])
|
||||||
|
params.delete ('grain')
|
||||||
if (params.has ('columns'))
|
if (params.has ('columns'))
|
||||||
{
|
{
|
||||||
const selected = new Set (params.get ('columns')!.split (','))
|
const selected = new Set (params.get ('columns')!.split (','))
|
||||||
@@ -237,17 +263,14 @@ export const normaliseCountsParams = (
|
|||||||
|
|
||||||
|
|
||||||
export const isCountsState = (params: URLSearchParams): boolean =>
|
export const isCountsState = (params: URLSearchParams): boolean =>
|
||||||
['state', 'trend', 'snapshot'].includes (params.get ('mode') ?? 'state')
|
params.get ('basis') !== 'history'
|
||||||
|
|
||||||
|
|
||||||
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
||||||
const result = new URLSearchParams (params)
|
const result = new URLSearchParams (params)
|
||||||
const hidden = isCountsState (params)
|
const hidden = isCountsState (params) ? ['latest'] : ['kind']
|
||||||
? ['axis', 'latest', 'user_id', 'resource_id']
|
for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
|
||||||
: ['at', 'grain', 'kind']
|
'nested_json', 'order', 'basis', 'grain', ...hidden])
|
||||||
if (isCountsState (params) && params.get ('mode') !== 'trend')
|
|
||||||
hidden.push ('from', 'before')
|
|
||||||
for (const key of ['columns', 'children', 'child_sort', 'nested_json', 'order', ...hidden])
|
|
||||||
result.delete (key)
|
result.delete (key)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -257,11 +280,26 @@ export const fetchCounts = (
|
|||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): Promise<CountsResult> => apiGet (isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
|
): Promise<CountsResult> => apiGet (
|
||||||
|
isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
|
||||||
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
|
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
|
||||||
...(isCountsState (params) ? { resource } : { }) } })
|
...(isCountsState (params) ? { resource } : { }) } })
|
||||||
|
|
||||||
|
|
||||||
|
export const exportCounts = (
|
||||||
|
resource: CountsResource,
|
||||||
|
params: URLSearchParams,
|
||||||
|
locale: LocaleCode,
|
||||||
|
format: 'json' | 'csv',
|
||||||
|
columns: string[],
|
||||||
|
headers: string[],
|
||||||
|
labels: Record<string, string>,
|
||||||
|
): Promise<Blob> => apiPost ('/counts/export', {
|
||||||
|
...Object.fromEntries (params), resource, format,
|
||||||
|
columns: columns.join (','), headers: JSON.stringify (headers),
|
||||||
|
labels: JSON.stringify (labels) }, { locale, responseType: 'blob' })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const stableValue = (value: unknown): unknown => {
|
const stableValue = (value: unknown): unknown => {
|
||||||
if (Array.isArray (value)) return value.map (stableValue)
|
if (Array.isArray (value)) return value.map (stableValue)
|
||||||
|
|||||||
@@ -212,3 +212,35 @@ export const countsImageURL = (value: string | null | undefined): string | null
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countsExportLabels = (
|
||||||
|
resource: CountsResource,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): Record<string, string> => {
|
||||||
|
const aliases: Record<string, string> = { versionNo: 'version_no',
|
||||||
|
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
|
||||||
|
const labels: Record<string, string> = { }
|
||||||
|
const visit = (fields: CountField[]) => {
|
||||||
|
for (const field of fields)
|
||||||
|
{
|
||||||
|
labels[aliases[field.key] ?? field.key] = translateUIMessage (
|
||||||
|
field.label, 'counts', locale).text
|
||||||
|
if (field.children) visit (field.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit (fieldsForCounts (resource))
|
||||||
|
for (const key of ['internal', 'external'])
|
||||||
|
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
|
||||||
|
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
|
||||||
|
labels[`category:${key}`] = translateUIMessage (
|
||||||
|
`common:categories.${key}`, 'counts', locale).text
|
||||||
|
for (const key of ['true', 'false'])
|
||||||
|
labels[`boolean:${key}`] = translateUIMessage (`boolean.${key}`, 'counts', locale).text
|
||||||
|
for (const key of ['ja', 'ain'])
|
||||||
|
labels[`language:${key}`] = translateUIMessage (
|
||||||
|
`history:languageNames.${key}`, 'counts', locale).text
|
||||||
|
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
|
||||||
|
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,94 @@
|
|||||||
|
import { isCountsState, selectedCountFields } from '@/lib/counts'
|
||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
|
|
||||||
import type { CountField, CountsStateRow } from '@/lib/counts'
|
import type { CountField, CountsResource, CountsResult } from '@/lib/counts'
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
|
export type CountsSummaryRow = {
|
||||||
|
key: string | number | null
|
||||||
|
name: string | null
|
||||||
|
count: number | null
|
||||||
|
kind?: 'internal' | 'external'
|
||||||
|
tagId?: number
|
||||||
|
deprecated?: string | null
|
||||||
|
at?: string }
|
||||||
|
|
||||||
export const countsStateFields = (trend: boolean): CountField[] => trend
|
|
||||||
? [{ key: 'period', label: 'counts:state.period' },
|
export const countsSummaryFields = (
|
||||||
{ key: 'count', label: 'counts:state.postCount' }]
|
resource: CountsResource,
|
||||||
: [{ key: 'kind', label: 'counts:columns.kind' },
|
params: URLSearchParams,
|
||||||
|
): CountField[] => (isCountsState (params) && params.get ('axis') === 'tags')
|
||||||
|
? [{ key: 'kind', label: 'counts:columns.kind' },
|
||||||
{ key: 'tag_id', label: 'counts:columns.resourceId' },
|
{ key: 'tag_id', label: 'counts:columns.resourceId' },
|
||||||
{ key: 'name', label: 'history:tagName' },
|
{ key: 'name', label: 'history:tagName' },
|
||||||
{ key: 'deprecated', label: 'counts:columns.deprecated' },
|
{ key: 'deprecated', label: 'counts:columns.deprecated' },
|
||||||
{ key: 'count', label: 'counts:state.postCount' }]
|
{ key: 'count', label: 'counts:state.postCount' }]
|
||||||
|
: [{ key: 'group', label: `counts:axes.${params.get ('axis') ?? 'month'}` },
|
||||||
|
{ key: 'count', label: isCountsState (params)
|
||||||
|
? `counts:state.totals.${resource}` : 'counts:metric' }]
|
||||||
|
|
||||||
|
|
||||||
export const countsStateCell = (
|
export const selectedCountsSummaryFields = (
|
||||||
row: CountsStateRow,
|
resource: CountsResource,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): CountField[] => selectedCountFields (
|
||||||
|
countsSummaryFields (resource, params), params, 'summary_columns', 'summary')
|
||||||
|
|
||||||
|
|
||||||
|
export const countsSummaryRows = (data: CountsResult): CountsSummaryRow[] =>
|
||||||
|
data.stateRows != null
|
||||||
|
? data.stateRows.map (row => ({ ...row, key: `${row.kind}:${row.tagId}` }))
|
||||||
|
: data.groups ?? []
|
||||||
|
|
||||||
|
|
||||||
|
export const countsSummaryCell = (
|
||||||
|
row: CountsSummaryRow,
|
||||||
key: string,
|
key: string,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): string => {
|
): { text: string; language?: string } => {
|
||||||
switch (key)
|
switch (key)
|
||||||
{
|
{
|
||||||
case 'period': return row.period ?? ''
|
case 'group': return row.key == null
|
||||||
case 'count': return row.count == null ? '—' : String (row.count)
|
? translateUIMessage ('history:bot', 'counts', locale)
|
||||||
case 'tag_id': return String (row.tagId)
|
: { text: row.name ?? String (row.key) }
|
||||||
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale).text
|
case 'count': return { text: row.count == null ? '—' : String (row.count) }
|
||||||
case 'name': return row.name ?? translateUIMessage ('notRegistered', 'counts', locale).text
|
case 'tag_id': return { text: String (row.tagId) }
|
||||||
|
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale)
|
||||||
|
case 'name': return row.name == null
|
||||||
|
? translateUIMessage ('notRegistered', 'counts', locale) : { text: row.name }
|
||||||
case 'deprecated': return row.deprecated
|
case 'deprecated': return row.deprecated
|
||||||
? translateUIMessage ('common:states.deprecated', 'counts', locale).text : ''
|
? translateUIMessage ('common:states.deprecated', 'counts', locale) : { text: '' }
|
||||||
default: return ''
|
default: return { text: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const countsStateDrill = (
|
export const countsSummaryDrill = (
|
||||||
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
at: string,
|
row: CountsSummaryRow,
|
||||||
resource = 'posts',
|
|
||||||
row?: CountsStateRow,
|
|
||||||
): string => {
|
): string => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
for (const key of ['sort', 'page', 'tag', 'from', 'before']) next.delete (key)
|
for (const key of ['sort', 'page']) next.delete (key)
|
||||||
next.set ('mode', 'snapshot')
|
next.set ('mode', 'details')
|
||||||
next.set ('at', at)
|
if (row.at) next.set ('at', row.at)
|
||||||
if (row != null)
|
if (row.tagId != null)
|
||||||
{
|
{
|
||||||
next.set ('kind', row.kind)
|
next.set ('kind', row.kind!)
|
||||||
next.set ('tag', String (row.tagId))
|
next.set ('tag', String (row.tagId))
|
||||||
}
|
}
|
||||||
|
else if (params.get ('axis') === 'user') next.set ('user_id', String (row.key ?? 'none'))
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const first = `${row.key}-01T00:00:00Z`
|
||||||
|
const date = new Date (first)
|
||||||
|
date.setUTCMonth (date.getUTCMonth () + 1)
|
||||||
|
const last = date.toISOString ()
|
||||||
|
const from = next.get ('from')
|
||||||
|
const before = next.get ('before')
|
||||||
|
next.set ('from', (from != null && Date.parse (from) > Date.parse (first)) ? from : first)
|
||||||
|
next.set ('before',
|
||||||
|
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
|
||||||
|
}
|
||||||
return `/counts/${resource}?${next}`
|
return `/counts/${resource}?${next}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"axis": "集計軸",
|
"axis": "集計軸",
|
||||||
"axes": {
|
"axes": {
|
||||||
"month": "月別(UTC)",
|
"month": "月別(UTC)",
|
||||||
"user": "$t(history:actor)"
|
"user": "$t(history:actor)",
|
||||||
|
"tags": "タグ別"
|
||||||
},
|
},
|
||||||
"metric": "履歴レコード件数",
|
"metric": "履歴レコード件数",
|
||||||
"total": "履歴レコード件数:{{count}}",
|
"total": "履歴レコード件数:{{count}}",
|
||||||
@@ -96,8 +97,8 @@
|
|||||||
"details": "時点明細",
|
"details": "時点明細",
|
||||||
"history": "履歴集計",
|
"history": "履歴集計",
|
||||||
"historyDetails": "履歴明細",
|
"historyDetails": "履歴明細",
|
||||||
"at": "指定時点(端末の時刻帯・未満)",
|
"at": "集計時点",
|
||||||
"boundary": "指定時点(UTC・未満)",
|
"boundary": "集計時点(UTC)",
|
||||||
"period": "時点(UTC)",
|
"period": "時点(UTC)",
|
||||||
"grain": "粒度(UTC)",
|
"grain": "粒度(UTC)",
|
||||||
"grains": {
|
"grains": {
|
||||||
@@ -111,5 +112,12 @@
|
|||||||
"tags": "タグ総数(内部タグ)",
|
"tags": "タグ総数(内部タグ)",
|
||||||
"assignments": "タグ付け総件数"
|
"assignments": "タグ付け総件数"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"measure": "指標",
|
||||||
|
"export": "エクスポート",
|
||||||
|
"exportFormat": "エクスポート形式",
|
||||||
|
"exportFormats": {
|
||||||
|
"json": "JSON",
|
||||||
|
"csv": "CSV"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import CountsControls from '@/components/counts/CountsControls'
|
import CountsControls from '@/components/counts/CountsControls'
|
||||||
import CountsStateResults from '@/components/counts/CountsStateResults'
|
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
|
||||||
import CountsResults from '@/components/counts/CountsResults'
|
import CountsResults from '@/components/counts/CountsResults'
|
||||||
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
@@ -13,12 +13,12 @@ import Pagination from '@/components/common/Pagination'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
|
import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
|
||||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsCSVCell } from '@/lib/countsDisplay'
|
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||||
import { countsStateCell, countsStateDrill, countsStateFields } from '@/lib/countsState'
|
import { selectedCountsSummaryFields } from '@/lib/countsState'
|
||||||
import { countsKeys } from '@/lib/queryKeys'
|
import { countsKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, inputClass } from '@/lib/utils'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
@@ -37,18 +37,22 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const mode = params.get ('mode') ?? 'state'
|
const mode = params.get ('mode') ?? 'summary'
|
||||||
const state = isCountsState (params)
|
const state = isCountsState (params)
|
||||||
const stateTable = mode === 'state' || mode === 'trend'
|
const cacheParams = new URLSearchParams (request)
|
||||||
const axis = params.get ('axis') ?? 'month'
|
cacheParams.set ('basis', state ? 'state' : 'history')
|
||||||
|
const [format, setFormat] = useState<'json' | 'csv'> ('json')
|
||||||
|
const [exporting, setExporting] = useState (false)
|
||||||
|
const [exportError, setExportError] = useState (false)
|
||||||
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
||||||
queryKey: countsKeys.index (resource, request.toString (), locale),
|
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||||
queryFn: () => fetchCounts (resource, request, locale), retry: false,
|
queryFn: () => fetchCounts (resource, params, locale), retry: false,
|
||||||
placeholderData: (previous, query) => {
|
placeholderData: (previous, query) => {
|
||||||
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
||||||
.get ('mode') ?? 'state'
|
|
||||||
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
||||||
&& previousMode === mode) ? previous : undefined
|
&& previousParams.get ('mode') === mode
|
||||||
|
&& previousParams.get ('axis') === params.get ('axis')
|
||||||
|
&& previousParams.get ('basis') === cacheParams.get ('basis')) ? previous : undefined
|
||||||
} })
|
} })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -57,16 +61,12 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
}, [canonical, source, setParams])
|
}, [canonical, source, setParams])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!state || data?.at == null || isPlaceholderData) return
|
if (data?.at == null || isPlaceholderData) return
|
||||||
const next = new URLSearchParams (canonical)
|
const next = new URLSearchParams (canonical)
|
||||||
if (!next.get ('at')) next.set ('at', data.at)
|
if (!next.get ('at')) next.set ('at', data.at)
|
||||||
if (mode === 'trend')
|
|
||||||
{
|
|
||||||
if (!next.get ('from') && data.from) next.set ('from', data.from)
|
|
||||||
if (!next.get ('before') && data.before) next.set ('before', data.before)
|
|
||||||
}
|
|
||||||
if (next.toString () !== canonical) setParams (next, { replace: true })
|
if (next.toString () !== canonical) setParams (next, { replace: true })
|
||||||
}, [canonical, data, isPlaceholderData, mode, setParams, state])
|
}, [canonical, data, isPlaceholderData, setParams])
|
||||||
|
|
||||||
const update = (key: string, value: string) => {
|
const update = (key: string, value: string) => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
@@ -75,29 +75,32 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
if (key === 'sort') next.delete ('page')
|
if (key === 'sort') next.delete ('page')
|
||||||
setParams (next)
|
setParams (next)
|
||||||
}
|
}
|
||||||
const exportCSV = () => {
|
const download = async () => {
|
||||||
const columns = selectedCountColumns (resource, params)
|
setExporting (true)
|
||||||
const stateFields = countsStateFields (mode === 'trend')
|
setExportError (false)
|
||||||
const rows = stateTable
|
try
|
||||||
? [stateFields.map (field => message (field.label).text),
|
{
|
||||||
...(data?.stateRows ?? []).map (row => stateFields.map (
|
const fields = mode === 'summary' ? selectedCountsSummaryFields (resource, params)
|
||||||
field => countsStateCell (row, field.key, locale)))]
|
: selectedCountColumns (resource, params).map (column =>
|
||||||
: (mode === 'summary'
|
fieldsForCounts (resource).find (field => field.key === column)!)
|
||||||
? [[message (`axes.${axis}`).text, message ('metric').text],
|
const file = await exportCounts (resource, params, locale, format,
|
||||||
...(data?.groups ?? []).map (group => [
|
fields.map (field => field.key), fields.map (field => message (field.label).text),
|
||||||
group.key == null ? message ('history:bot').text : group.name ?? String (group.key),
|
countsExportLabels (resource, locale))
|
||||||
String (group.count)])]
|
const url = URL.createObjectURL (file)
|
||||||
: [columns.map (column => message (
|
|
||||||
fieldsForCounts (resource).find (field => field.key === column)!.label).text),
|
|
||||||
...(data?.versions ?? []).map (row => columns.map (
|
|
||||||
column => countsCSVCell (row, column, resource, params, locale)))])
|
|
||||||
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
|
|
||||||
type: 'text/csv;charset=utf-8' }))
|
|
||||||
const link = document.createElement ('a')
|
const link = document.createElement ('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `counts-${resource}.csv`
|
link.download = `counts-${resource}.${format}`
|
||||||
link.click ()
|
link.click ()
|
||||||
URL.revokeObjectURL (url)
|
setTimeout (() => URL.revokeObjectURL (url), 1000)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
setExportError (true)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setExporting (false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,19 +117,23 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
lang={message ('target').language}>
|
lang={message ('target').language}>
|
||||||
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{(['posts', 'tags'] as const).map (target => (
|
{(['posts', 'tags'] as const).map (target => {
|
||||||
<li key={target}><PrefetchLink to={`/counts/${target}`}
|
const next = new URLSearchParams (params)
|
||||||
|
for (const key of ['resource_id', 'tag', 'columns', 'summary_columns',
|
||||||
|
'sort', 'page', 'children', 'child_sort']) next.delete (key)
|
||||||
|
return <li key={target}><PrefetchLink
|
||||||
|
to={`/counts/${target}?${normaliseCountsParams (target, next)}`}
|
||||||
aria-current={target === resource ? 'page' : undefined}
|
aria-current={target === resource ? 'page' : undefined}
|
||||||
className={cn (
|
className={cn ('block py-1', target === resource && 'font-bold underline')}>
|
||||||
'block py-1', target === resource && 'font-bold underline')}>
|
|
||||||
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
|
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
|
||||||
</PrefetchLink></li>))}
|
</PrefetchLink></li>
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</SidebarComponent>
|
</SidebarComponent>
|
||||||
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
||||||
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
||||||
<div className="relative shrink-0 pr-16">
|
<div className="shrink-0">
|
||||||
<CountsControls key={`${resource}:${controlParams}`}
|
<CountsControls key={`${resource}:${controlParams}`}
|
||||||
resource={resource} applied={params}
|
resource={resource} applied={params}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
@@ -135,32 +142,40 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
execute={next => {
|
execute={next => {
|
||||||
if (next.toString () === canonical) void refetch ()
|
if (next.toString () === canonical) void refetch ()
|
||||||
else setParams (next)
|
else setParams (next)
|
||||||
}}/>
|
}} actions={<>
|
||||||
<Button type="button" variant="outline" size="sm" className="absolute right-0 top-0"
|
<select value={format} aria-label={message ('exportFormat').text}
|
||||||
title={message ('csvScope').text} lang={message ('csvScope').language}
|
lang={message ('exportFormat').language}
|
||||||
disabled={data == null || isError || isPlaceholderData} onClick={exportCSV}>
|
className={inputClass (false, 'w-auto py-1')}
|
||||||
{label ('csv')}</Button>
|
onChange={event => setFormat (event.target.value as 'json' | 'csv')}>
|
||||||
|
{(['json', 'csv'] as const).map (value => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{message (`exportFormats.${value}`).text}</option>))}
|
||||||
|
</select>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
disabled={data == null || isError || isPlaceholderData
|
||||||
|
|| exporting || !params.get ('at')}
|
||||||
|
onClick={() => void download ()}>{label ('export')}</Button>
|
||||||
|
</>}/>
|
||||||
|
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
</div>
|
</div>
|
||||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
{data != null && <>
|
{data != null && <>
|
||||||
{state && data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
{data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
<span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
|
<span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
|
||||||
{data.totals && (['posts', 'tags', 'assignments'] as const).map (key => (
|
{data.totals && (['posts', 'tags', 'assignments'] as const).map (key => (
|
||||||
<span key={key}>{label (`state.totals.${key}`)}:
|
<span key={key}>{label (`state.totals.${key}`)}:
|
||||||
{key !== 'assignments' && data.totals![key] != null
|
{data.totals![key] ?? '—'}
|
||||||
? <PrefetchLink to={countsStateDrill (params, data.at!, key)}>
|
|
||||||
{data.totals![key]}</PrefetchLink>
|
|
||||||
: data.totals![key] ?? '—'}
|
|
||||||
</span>))}
|
</span>))}
|
||||||
</div>}
|
</div>}
|
||||||
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>}
|
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>}
|
||||||
{stateTable ? <CountsStateResults data={data} params={params}
|
{mode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||||
|
data={data} params={params}
|
||||||
pending={isPlaceholderData} update={update}/> : (
|
pending={isPlaceholderData} update={update}/> : (
|
||||||
<CountsResults resource={resource} data={data} params={params}
|
<CountsResults resource={resource} data={data} params={params}
|
||||||
pending={isPlaceholderData} update={update}/>)}
|
pending={isPlaceholderData} update={update}/>)}
|
||||||
<Pagination page={data.page} totalPages={Math.ceil (
|
<Pagination page={data.page} totalPages={Math.ceil (
|
||||||
((stateTable || mode === 'summary') ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
||||||
</>}
|
</>}
|
||||||
</MainArea>
|
</MainArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする