このコミットが含まれているのは:
2026-10-26 12:19:43 +09:00
コミット 3e8f209542
31個のファイルの変更、1676行の追加、394行の削除
+4 -2
ファイルの表示
@@ -3,7 +3,9 @@ class CountsExportsController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
exporter = CountsExporter.new(request.request_parameters.merge(request.query_parameters), payload = request.request_parameters.except('authenticity_token')
.merge(request.query_parameters)
exporter = CountsExporter.new(payload,
language_code: locale.language_code) language_code: locale.language_code)
response.headers['Content-Type'] = exporter.format == 'json' ? response.headers['Content-Type'] = exporter.format == 'json' ?
'application/json; charset=utf-8' : 'text/csv; charset=utf-8' 'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
@@ -14,7 +16,7 @@ class CountsExportsController < ApplicationController
# Rack::ETag otherwise materialises the response to calculate a digest. # Rack::ETag otherwise materialises the response to calculate a digest.
response.headers['Last-Modified'] = Time.current.httpdate response.headers['Last-Modified'] = Time.current.httpdate
self.response_body = exporter.body self.response_body = exporter.body
rescue ArgumentError, TypeError, JSON::ParserError rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request head :bad_request
end end
end end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class CountsTablesController < ApplicationController
def index
resolve_locale!
render json: CountsTableQuery.new(request.query_parameters).call
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
end
+7 -54
ファイルの表示
@@ -449,24 +449,8 @@ class PostsController < ApplicationController
private private
def tag_filter_references literals def tag_filter_references literals
raw = params[:tag_refs] PostTagSearch.parse_references(params[:tag_refs], literals)
return nil if raw.blank? rescue ArgumentError, JSON::ParserError, TypeError
references = JSON.parse(raw)
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
ids = [reference['tag_id'], reference['external_tag_id']].compact
((ids.any? || reference['missing'] == true) &&
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
end
raise ActionController::BadRequest unless valid
return nil unless references.map { _1['name'] } == literals
references
rescue JSON::ParserError, TypeError
raise ActionController::BadRequest raise ActionController::BadRequest
end end
@@ -482,17 +466,7 @@ class PostsController < ApplicationController
def filter_posts_by_tags locale, tag_names, match_type def filter_posts_by_tags locale, tag_names, match_type
references = tag_filter_references(tag_names) references = tag_filter_references(tag_names)
literals = tag_names.each_with_index.map do |raw_name, index| literals = PostTagSearch.literals(locale, tag_names.join(' '), references:)
name = raw_name.sub(/\Anot:/i, '')
name = TagName.canonicalise(locale, name).first unless references
reference = references&.[](index)
negative = if reference&.key?('negative')
reference['negative']
else
raw_name.downcase.start_with?('not:')
end
{ name:, negative:, reference: }
end
return Post.all if literals.empty? return Post.all if literals.empty?
@@ -523,31 +497,10 @@ class PostsController < ApplicationController
end end
def tagged_post_ids_for locale, name, reference: nil def tagged_post_ids_for locale, name, reference: nil
if reference internal, external = PostTagSearch.references(locale, { name:, reference: })
matches = Post.none Post.where(id: PostTag.where(tag_id: Array(internal)).select(:post_id))
if reference['tag_id'] .or(Post.where(id: PostExternalTag.where(external_tag_id: Array(external))
matches = matches.or(Post.where(id: PostTag.where( .select(:post_id))).select(:id)
tag_id: reference['tag_id']).select(:post_id)))
end
if reference['external_tag_id']
matches = matches.or(Post.where(id: PostExternalTag.where(
external_tag_id: reference['external_tag_id']).select(:post_id)))
end
return matches.select(:id)
end
posts_by_internal_tags =
Post
.joins(tags: :tag_names)
.where(tag_names: { language_code: locale.language_code, name: })
.select(:id)
posts_by_external_tags =
Post
.joins(:external_tags)
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
end end
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags, sections
+98
ファイルの表示
@@ -0,0 +1,98 @@
class CountsAxes
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain)
connection = ApplicationRecord.connection
# Use the installed TZInfo database, including DST, rather than MySQL timezone tables.
first = Time.utc(1900)
last = Time.utc(2101)
offset = timezone.period_for_utc(first).utc_total_offset
clauses = timezone.transitions_up_to(last, first).filter_map do |transition|
next if transition.offset.utc_total_offset == offset
previous = offset
offset = transition.offset.utc_total_offset
"WHEN #{ expression } < #{ connection.quote(transition.at.to_time) } THEN #{ previous }"
end
seconds = clauses.empty? ? offset.to_s : "CASE #{ clauses.join(' ') } ELSE #{ offset } END"
local = "DATE_ADD(#{ expression }, INTERVAL (#{ seconds }) SECOND)"
case grain
when 'year' then "DATE_FORMAT(#{ local }, '%Y')"
when 'quarter' then "CONCAT(YEAR(#{ local }), '-Q', QUARTER(#{ local }))"
when 'month' then "DATE_FORMAT(#{ local }, '%Y-%m')"
when 'week' then "DATE_FORMAT(#{ local }, '%x-W%v')"
when 'day' then "DATE_FORMAT(#{ local }, '%Y-%m-%d')"
else "DATE_FORMAT(#{ local }, '%Y-%m-%d %H:00')"
end
end
attr_reader :rows, :columns, :all
def initialize(population)
@population = population
@definition = population.definition
@rows = parse(population.params.fetch('rows', '[]'))
@columns = parse(population.params.fetch('cols', '[]'))
@all = rows + columns
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
end
def expressions
all.map do |axis|
field = axis.fetch('field')
case field
when 'tags'
"CASE WHEN tag_entry.tag_id IS NOT NULL " +
"THEN CONCAT('internal:', tag_entry.tag_id) " +
"ELSE CONCAT('external:', tag_entry.external_tag_id) END"
when 'changed_field' then 'changed.field_key'
else
expression = @definition.expression(field)
@definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
end
end
def joins
result = ''
if all.any? { _1['field'] == 'tags' }
result += <<~SQL.squish
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
SQL
end
if all.any? { _1['field'] == 'changed_field' }
fields = @definition.changes.map do |field|
"SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key"
end.join(' UNION ALL ')
condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ')
result += " JOIN (#{ fields }) changed ON (#{ condition })"
end
result
end
private
def parse(value)
axes = JSON.parse(value)
raise ArgumentError unless axes.is_a?(Array)
axes.each do |axis|
raise ArgumentError unless axis.is_a?(Hash) && (axis.keys - ['field', 'grain']).empty?
raise ArgumentError unless @definition.axes(@population.history).include?(axis['field'])
if @definition.fields[axis['field']] == :datetime
raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month'))
elsif axis.key?('grain')
raise ArgumentError
end
end
axes
end
end
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@ require 'set'
# Export identifiers are stable; UI translations are supplied separately as column labels. # Export identifiers are stable; UI translations are supplied separately as column labels.
class CountsExportProjection class CountsExportProjection
ROOT_KEYS = { 'resource_id' => 'resource_id', 'version_no' => 'version_no', ROOT_KEYS = { 'event_type' => 'event_type', 'resource_id' => 'resource_id', 'version_no' => 'version_no',
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url', 'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url', 'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms', 'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
+8
ファイルの表示
@@ -7,6 +7,12 @@ class CountsExporter
attr_reader :format, :resource attr_reader :format, :resource
def initialize(params, language_code:) def initialize(params, language_code:)
if params.key?('definition')
@table_export = CountsTableExport.new(params)
@format = @table_export.format
@resource = @table_export.resource
return
end
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
raise ArgumentError if (params.keys - allowed).any? raise ArgumentError if (params.keys - allowed).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) } raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@@ -42,6 +48,8 @@ class CountsExporter
end end
def body def body
return @table_export.body if @table_export
Enumerator.new do |output| Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do ApplicationRecord.transaction(isolation: :repeatable_read) do
first_row = true first_row = true
+208
ファイルの表示
@@ -0,0 +1,208 @@
# Population first, then filters. Previous versions are joined before period filtering.
class CountsPopulation
attr_reader :definition, :params, :at, :history, :scope, :timezone
def initialize(params)
@params = params
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
@history = params.fetch('definition', 'latest') == 'history'
raise ArgumentError unless ['latest', 'history'].include?(params.fetch('definition', 'latest'))
@timezone = TZInfo::Timezone.get(params.fetch('timezone', 'UTC'))
@at = timestamp(params['execution_at']) || Time.current.utc
raise ArgumentError if at > Time.current + 1.second
@scope = definition.model.all
make_population
apply_filters
rescue TZInfo::InvalidTimezoneIdentifier
raise ArgumentError
end
def timestamp(value, end_date: false)
return nil if value.nil? || value == ''
raise ArgumentError unless value.is_a?(String)
if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
date = Date.iso8601(value)
date += 1 if end_date
timezone.local_to_utc(Time.utc(date.year, date.month, date.day))
else
raise ArgumentError unless value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
time = Time.iso8601(value).utc
raise ArgumentError unless time.year.between?(1900, 2100)
time
end
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound, Date::Error
raise ArgumentError
end
def latest_sql(row = 'v', cutoff = nil)
cutoff ||= history ? at : timestamp(params['at']) || at
keys = definition.keys.map { "newer.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
bound = definition.model.connection.quote(cutoff)
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM #{ definition.table } newer
WHERE #{ keys } AND newer.version_no > #{ row }.version_no
AND newer.#{ definition.time_column } <= #{ bound })
SQL
end
def changed_sql(key)
column = definition.column(key)
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
end
private
def make_population
table = definition.table
@scope = @scope.from("#{ table } v").select('v.*')
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
'AND previous.version_no = v.version_no - 1')
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
unless history
point = timestamp(params['at']) || at
raise ArgumentError if point > at
@scope = @scope.where("v.#{ definition.time_column } <= ?", point)
.where(Arel.sql(latest_sql('v', point))).where.not('v.event_type' => 'discard')
return
end
first = timestamp(params['from'])
last = timestamp(params['through'], end_date: true)
raise ArgumentError if first && last && first > last
@scope = @scope.where('v.created_at >= ?', first) if first
if last
operator = params['through'].length == 10 ? '<' : '<='
@scope = @scope.where("v.created_at #{ operator } ?", last)
end
end
def apply_filters
['resource_id', 'user'].each do |key|
value = params[key == 'user' ? 'user_id' : key]
next if value.nil? || value == ''
raise ArgumentError unless value.match?(/\A[0-9]+\z/)
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
end
apply_tags if params['tags'].present? || params['tag'].present?
conditions = JSON.parse(params.fetch('conditions', '[]'))
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
conditions.each { apply_condition(_1) }
drills = JSON.parse(params.fetch('drill', '[]'))
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
drills.each do |entry|
key = entry.fetch('field')
if key == 'tags'
@scope = @scope.where(snapshot_membership(entry.fetch('value')))
elsif key == 'changed_field'
raise ArgumentError unless history && definition.changes.include?(entry['value'])
@scope = @scope.where(Arel.sql(changed_sql(entry['value'])))
elsif definition.fields[key] == :datetime && entry['grain']
@scope = @scope.where(definition.expression(key) => nil) if entry['value'].nil?
if entry['value']
expression = CountsAxes.time_expression(definition.expression(key),
entry['grain'], timezone)
@scope = @scope.where("#{ expression } = ?", entry['value'])
end
else
raise ArgumentError unless definition.axes(history).include?(key)
@scope = @scope.where(definition.expression(key) => entry['value'])
end
end
end
def apply_condition(entry)
raise ArgumentError unless entry.is_a?(Hash)
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
key = entry.fetch('field')
type = definition.fields.fetch(key) { raise ArgumentError }
operator = entry.fetch('operator')
raise ArgumentError unless CountsResourceDefinition::OPERATORS.fetch(type, []).include?(operator)
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
expression = definition.expression(key)
blank = type == :string || type == :enum ?
"(#{ expression } IS NULL OR #{ expression } = '')" : "#{ expression } IS NULL"
if entry['blank']
@scope = @scope.where(Arel.sql(blank))
return
end
values = [entry['value'], entry['end']]
raise ArgumentError unless values.all? { _1.nil? || _1.is_a?(String) }
return if values.all? { _1.nil? || _1 == '' }
parsed = values.map do |value|
next nil if value.nil? || value == ''
case type
when :integer
raise ArgumentError unless value.match?(/\A-?\d+\z/)
value.to_i
when :datetime then timestamp(value)
else value
end
end
if operator == 'range'
@scope = @scope.where("#{ expression } >= ?", parsed[0]) if parsed[0]
@scope = @scope.where("#{ expression } <= ?", parsed[1]) if parsed[1]
return
end
return unless parsed[0]
sql_operator = { 'eq' => '=', 'ne' => '<>', 'gt' => '>', 'lt' => '<',
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
sql = "#{ expression } #{ sql_operator } ?"
sql = "(#{ sql } OR #{ blank })" if operator == 'ne'
@scope = @scope.where(sql, parsed[0])
end
def snapshot_membership(value)
kind, identifier = value.to_s.split(':', 2)
raise ArgumentError unless ['internal', 'external'].include?(kind)
raise ArgumentError unless identifier&.match?(/\A[1-9]\d*\z/)
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
definition.model.sanitize_sql_array([
"JSON_CONTAINS(v.tags_json, JSON_OBJECT('#{ key }', CAST(? AS UNSIGNED)))",
identifier.to_i])
end
def apply_tags
raise ArgumentError unless definition.resource == 'posts'
if params['tag'].present?
@scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
end
return unless params['tags'].present?
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
refs = PostTagSearch.parse_references(params['tag_refs'], params['tags'].split)
raw = params['tags']
literals = PostTagSearch.literals(locale, raw, references: refs)
predicates = literals.map do |literal|
internal, external = PostTagSearch.references(locale, literal)
parts = Array(internal).map { snapshot_membership("internal:#{ _1 }") } +
Array(external).map { snapshot_membership("external:#{ _1 }") }
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
literal[:negative] ? "NOT #{ expression }" : expression
end
return if predicates.empty?
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
end
end
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
# Read-only history queries. SQL identifiers and expressions are server-owned. # Read-only history queries. SQL identifiers and expressions are server-owned.
class CountsQuery class CountsQuery
AXES = ['month', 'user'].freeze AXES = ['month', 'user'].freeze
COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'user', 'created_at'].freeze COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'event_type', 'user', 'created_at'].freeze
COLUMNS = { COLUMNS = {
'posts' => COMMON_COLUMNS + [ 'posts' => COMMON_COLUMNS + [
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms', 'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
+65
ファイルの表示
@@ -0,0 +1,65 @@
# Server-owned version identities, attributes and field types. No client SQL is accepted.
class CountsResourceDefinition
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
DEFINITIONS = {
'posts' => [PostVersion, ['post_id'], {
'title' => :string, 'url' => :string, 'thumbnail_base' => :string,
'tags_json' => :json, 'parent_post_ids' => :string, 'video_ms' => :integer,
'original_created_from' => :datetime, 'original_created_before' => :datetime }],
'tags' => [TagVersion, ['tag_id'], {
'name' => :string, 'names' => :json, 'category' => :enum, 'aliases' => :string,
'parent_tag_ids' => :string, 'deprecated_at' => :datetime }],
'nico_tags' => [NicoTagVersion, ['tag_id'], {
'name' => :string, 'linked_tags' => :string }],
'tag_descriptions' => [TagDescriptionVersion, ['tag_id', 'language_code'], {
'language_code' => :enum, 'body' => :string,
'source_language_code' => :enum, 'source_version_no' => :integer }],
'tag_groups' => [TagGroupVersion, ['tag_group_id'], {
'name' => :string, 'description' => :string,
'members' => :json, 'placements' => :json }],
'materials' => [MaterialVersion, ['material_id'], {
'url' => :string, 'parent_id' => :integer, 'tag_id' => :integer,
'tag_name' => :string, 'tag_category' => :enum,
'discarded_at' => :datetime, 'updated_at' => :datetime,
'updated_by_user_id' => :integer, 'export_paths_json' => :json,
'file_blob_id' => :integer, 'file_filename' => :string,
'file_content_type' => :string, 'file_byte_size' => :integer,
'file_checksum' => :string, 'file_sha256' => :string,
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
OPERATORS = {
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'] }.freeze
attr_reader :model, :keys, :fields, :resource
def initialize(resource)
@resource = resource
@model, @keys, attributes = DEFINITIONS.fetch(resource) { raise ArgumentError }
@fields = COMMON.merge(attributes)
end
def table = model.table_name
def time_column = 'created_at'
def column(key)
raise ArgumentError unless fields.key?(key)
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
def axes(history)
fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
end
def metadata
fields.map do |key, type|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
operators: OPERATORS.fetch(type, []) }
end
end
end
+172
ファイルの表示
@@ -0,0 +1,172 @@
require 'csv'
# Export uses the exact same population and grouped cells as the interactive table.
class CountsTableExport
EXTRA = ['format', 'columns', 'headers', 'labels', 'children', 'child_sort',
'order', 'nested_json', 'summary_columns', 'basis', 'axis', 'latest'].freeze
attr_reader :format, :resource
def initialize(params)
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash)
@selected = params.key?('children') ? params['children'].split(',') : nil
@order = params.fetch('order', '').split(',')
@child_sort = params.fetch('child_sort', '').split(',').map { _1.split(':', -1) }
@child_sort.each do |path, direction, *extra|
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/)
end
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
raise ArgumentError unless ['json', 'csv'].include?(format)
@query = CountsTableQuery.new(params.slice(*CountsTableQuery::KEYS)
.merge('page' => '1', 'limit' => '100'))
@summary = @query.mode == 'summary'
@columns = params.fetch('columns', '').split(',')
@headers = JSON.parse(params.fetch('headers', '[]'))
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
unless @summary
if ['posts', 'tags'].include?(resource)
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
else
allowed = @query.population.definition.fields.keys + ['is_latest']
raise ArgumentError if (@columns - allowed).any?
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
end
end
end
def body
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
@language_names = Language.pluck(:code, :name).to_h
started = false
first = true
@query.each_page do |page|
unless started
metadata = page.except(:versions, :cells, :axis_labels, :column_values).merge(
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
fields: @query.population.definition.metadata,
columns: @columns, column_labels: @headers,
order: @params['order'], children: @params['children'],
sort: @params.fetch('sort', ''), child_sort: @params['child_sort'])
output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
headers = @summary ? summary_headers : @headers
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
end
started = true
end
(page[:cells] || page[:versions]).each do |row|
record = project(row)
if format == 'json'
output << ',' unless first
output << record.to_json
else
values = if @summary
row[:values].each_with_index.map do |value, index|
page[:axis_labels].fetch("a#{ index }", {}).fetch(value.to_s, value)
end + [row[:count]]
elsif @projection
@projection.csv_record(record, row)
else
record.map { |key, value| csv_logical(key, value) }
end
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
end
first = false
end
end
output << ']}' if format == 'json'
end
end
end
private
def summary_headers
labels = @labels
@query.axes.all.map { labels.fetch(_1['field'], _1['field']) } +
[labels.fetch('count', 'count')]
end
def project(row)
return { row: row[:values].first(@query.axes.rows.length),
column: row[:values].drop(@query.axes.rows.length), count: row[:count] } if @summary
return @projection.project(row) if @projection
@columns.to_h do |column|
key = column == 'user' ? 'created_by_user' : column
value = row[key]
value = project_nested(value, key) if ['members', 'placements', 'export_paths_json'].include?(key)
[key, value]
end
end
def camel(key)
key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
end
def project_nested(value, path)
rows = path == 'export_paths_json' ? (value || {}).map { |profile, target|
{ 'profile' => profile, 'path' => target }
} : Array(value)
sorts = @child_sort.filter_map do |key, direction|
parent, field = key.split('/', 2)
[field, direction] if parent == path
end
rows = rows.each_with_index.sort do |(first, a), (last, b)|
compared = 0
sorts.each do |field, direction|
left = first.find { |key, _| camel(key) == field }&.last
right = last.find { |key, _| camel(key) == field }&.last
compared = if left.nil? || right.nil?
left.nil? == right.nil? ? 0 : (left.nil? ? 1 : -1)
else
(left.to_s <=> right.to_s) * (direction == 'asc' ? 1 : -1)
end
break unless compared.zero?
end
compared.zero? ? a <=> b : compared
end.map(&:first)
rows.map do |row|
fields = row.keys.select { !@selected || @selected.include?("#{ path }.#{ camel(_1) }") }
ordered = (@order + fields.map { "#{ path }.#{ camel(_1) }" }).uniq
.filter_map { |item| fields.find { "#{ path }.#{ camel(_1) }" == item } }
ordered.to_h do |key|
item = row[key]
item = project_nested(item, "#{ path }.#{ camel(key) }") if item.is_a?(Array)
[key, item]
end
end
end
def csv_logical(key, value)
return value ? @labels.fetch('latest', '') : '' if key == 'is_latest'
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
return @query.population.timezone.utc_to_local(value.utc).iso8601
end
if key == 'language_code' || key == 'source_language_code'
return @language_names[value]
end
if value.is_a?(Hash)
return value.to_h { |field, item| [@labels.fetch(camel(field.to_s), ''), csv_logical(field, item)] }
end
return value.map { csv_logical(key, _1) } if value.is_a?(Array)
@labels.fetch("#{ key }:#{ value }", value)
end
def safe(value)
return value if value.is_a?(Numeric)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
+226
ファイルの表示
@@ -0,0 +1,226 @@
# Common entrypoint for six version resources; legacy endpoints keep their contracts.
class CountsTableQuery
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale'].freeze
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
raise ArgumentError if (params.keys - KEYS).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@population = CountsPopulation.new(params)
@definition = population.definition
@connection = @definition.model.connection
@axes = CountsAxes.new(population)
@page = Integer(params.fetch('page', '1'), 10)
@limit = Integer(params.fetch('limit', '20'), 10)
@mode = params.fetch('mode', 'summary')
raise ArgumentError unless page.between?(1, 1_000_000) && limit.between?(1, 100)
raise ArgumentError unless ['summary', 'details'].include?(mode)
@sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) }
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
@definition.fields.keys + ['is_latest'] +
(@definition.resource == 'posts' ? ['tags', 'parents', 'original_created_at'] : []) +
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
@sort.each do |key, direction, *extra|
measure = mode == 'summary' && key&.match?(/\Ameasure_[0-9a-f]+\z/)
measure_values(key) if measure
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
raise ArgumentError
end
end
end
def call
ApplicationRecord.transaction do
metadata.merge(mode == 'summary' ? summary : details)
end
end
def metadata
{ page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest',
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
fields: @definition.metadata, count: population.scope.except(:select).count }
end
def summary
expressions = axes.expressions
selected = expressions.each_with_index.map { |expression, index| "#{ expression } AS a#{ index }" }
source = population.scope
source = source.joins(axes.joins) unless axes.joins.empty?
sql = if expressions.empty?
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
else
source.except(:select).group(*expressions.map { Arel.sql(_1) })
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', ')))
.to_sql
end
prefix = "WITH cells AS (#{ sql })"
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
# Page whole row tuples so a pivot row is never split across display pages.
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }"
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
if key == 'count' || key.start_with?('measure_')
condition = if key == 'count'
'TRUE'
else
measure_values(key).each_with_index.map do |value, offset|
"a#{ axes.rows.length + offset } <=> #{ @connection.quote(value) }"
end.join(' AND ').presence || 'TRUE'
end
["MAX(CASE WHEN #{ condition } THEN count END) AS sort#{ index }",
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"]
elsif row_keys.include?(key)
[nil, "#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"]
end
end
extras = row_sort.filter_map(&:first)
order_rows = row_sort.map(&:last) + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
order_rows = ['singleton ASC'] if order_rows.empty?
group_sql = "SELECT #{ ([row_selection] + extras).join(', ') } FROM cells #{ row_group }"
total = @connection.select_value(
"#{ prefix } SELECT COUNT(*) FROM (#{ group_sql }) row_groups").to_i
join = row_keys.empty? ? 'TRUE' : row_keys.map { "cells.#{ _1 } <=> page_rows.#{ _1 }" }.join(' AND ')
order_cols = @sort.filter_map do |key, direction|
"cells.#{ key } #{ direction.upcase }" if key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
end
records = @connection.select_all(<<~SQL)
#{ prefix }, row_groups AS (#{ group_sql }),
numbered AS (SELECT row_groups.*,
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups),
page_rows AS (SELECT * FROM numbered
WHERE position > #{ (page - 1) * limit } AND position <= #{ page * limit })
SELECT cells.* FROM cells JOIN page_rows ON #{ join }
ORDER BY #{ (['page_rows.position'] + order_cols + axes.all.each_index.map { "cells.a#{ _1 }" }).join(', ') }
SQL
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
column_values = if column_keys.empty?
[[]]
else
@connection.select_rows(
"#{ prefix } SELECT DISTINCT #{ column_keys.join(', ') } FROM cells " +
"ORDER BY #{ column_keys.map { "#{ _1 } IS NULL, #{ _1 }" }.join(', ') }")
end
{ group_count: total, column_values:, cells: records.map do |row|
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
end, axis_labels: axis_labels(records, column_values) }
end
def details
query = population.scope.select(Arel.sql(
"(#{ population.latest_sql }) AS counts_latest"))
order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id DESC']).join(', ')))
.limit(limit).offset((page - 1) * limit)
rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource)
values = CountsVersionRepr.many(rows, resource: @definition.resource,
language_code: locale.language_code,
at: population.history ? population.at : population.timestamp(@params['at']) || population.at)
values.each_with_index { |value, index| value[:event_type] = rows[index].event_type }
else
users = User.where(id: rows.map(&:created_by_user_id)).pluck(:id, :name).to_h
values = rows.map do |row|
attributes = @definition.fields.keys.to_h do |key|
[key, row.public_send(@definition.column(key))]
end
attributes.merge('id' => row.id, 'resource_id' => row[@definition.keys.first],
'is_latest' => row.attributes['counts_latest'].to_i == 1,
'created_by_user' => row.created_by_user_id &&
{ id: row.created_by_user_id, name: users[row.created_by_user_id] })
end
end
{ versions: values }
end
def each_page
total = nil
loop do
result = call
total ||= mode == 'summary' ? result[:group_count] : result[:count]
yield result
break if page * limit >= total
@page += 1
end
end
private
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
def measure_values(key)
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
unless (value.is_a?(Array) && value.length == axes.columns.length &&
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
raise ArgumentError
end
value
end
def detail_expression(key)
return "(#{ population.latest_sql })" if key == 'is_latest'
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at',
'original_created_at' => 'original_created_from' }
field = aliases.fetch(key, key)
expression = @definition.expression(field)
@definition.fields[field] == :json ? "CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin" : expression
end
def sort_sql
@sort.flat_map do |key, direction|
expression = yield key
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
end
def axis_labels(records, column_values)
result = {}
axes.all.each_with_index do |axis, index|
values = records.map { _1["a#{ index }"] }
if index >= axes.rows.length
values += column_values.map { _1[index - axes.rows.length] }
end
values = values.compact.uniq
labels = case axis['field']
when 'user'
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
when 'language_code', 'source_language_code'
Language.where(code: values).pluck(:code, :name).to_h
when 'tags'
tag_labels(values)
else {}
end
result["a#{ index }"] = labels
end
result
end
def tag_labels(values)
internal = values.grep(/\Ainternal:/).map { _1.split(':').last.to_i }
external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i }
point = population.timestamp(@params['at']) || population.at
labels = {}
[[TagVersion, internal, 'internal'], [NicoTagVersion, external, 'external']].each do |model, ids, kind|
next if ids.empty?
table = model.table_name
model.where(tag_id: ids).where('created_at <= ?', point)
.where("NOT EXISTS (SELECT 1 FROM #{ table } newer " +
"WHERE newer.tag_id = #{ table }.tag_id AND newer.created_at <= ? " +
"AND newer.version_no > #{ table }.version_no)", point).each do |row|
names = row.respond_to?(:names) ? row.names : nil
name = names&.dig(locale.language_code, 'name') || row.name
labels["#{ kind }:#{ row.tag_id }"] = name
end
end
labels
end
end
+42
ファイルの表示
@@ -0,0 +1,42 @@
# Shared literal parsing and reference resolution for current and snapshot post search.
class PostTagSearch
def self.parse_references(raw, literals)
return nil if raw.blank?
references = JSON.parse(raw)
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
ids = [reference['tag_id'], reference['external_tag_id']].compact
((ids.any? || reference['missing'] == true) &&
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
end
raise ArgumentError unless valid
references.map { _1['name'] } == literals ? references : nil
end
def self.literals(locale, raw, references: nil)
raw.split.each_with_index.filter_map do |token, index|
next if token.downcase == 'type:or'
reference = references&.[](index)
name = token.sub(/\Anot:/i, '')
name = TagName.canonicalise(locale, name).first unless reference
negative = reference&.key?('negative') ? reference['negative'] :
token.downcase.start_with?('not:')
{ name:, negative:, reference: }
end
end
def self.references(locale, literal)
reference = literal[:reference]
return [reference['tag_id'], reference['external_tag_id']] if reference
[TagName.where(language_code: locale.language_code, name: literal[:name])
.distinct.pluck(:tag_id),
ExternalTag.where("CONCAT(platform, ':', name) = ?", literal[:name]).pluck(:id)]
end
end
+1
ファイルの表示
@@ -1,4 +1,5 @@
Rails.application.routes.draw do Rails.application.routes.draw do
get 'counts/query', to: 'counts_tables#index'
get 'counts/export', to: 'counts_exports#index' get 'counts/export', to: 'counts_exports#index'
post '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'
+8
ファイルの表示
@@ -202,6 +202,14 @@ export const pageRoutes = (
handle: { preload: CountsResourcePage.preload } }, handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>, { path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload } }, handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/nico_tags', element: <CountsResourcePage resource="nico_tags"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tag_descriptions', element: <CountsResourcePage resource="tag_descriptions"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tag_groups', element: <CountsResourcePage resource="tag_groups"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/materials', element: <CountsResourcePage resource="materials"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'more', element: <MorePage/>, { path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } }, handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }] { path: '*', element: <NotFound/>, handle: { notFound: true } }]
+12 -8
ファイルの表示
@@ -8,13 +8,13 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0') const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (value: Date) => { const toDateTimeLocalValue = (value: Date, seconds = false) => {
const y = value.getFullYear () const y = value.getFullYear ()
const m = pad (value.getMonth () + 1) const m = pad (value.getMonth () + 1)
const day = pad (value.getDate ()) const day = pad (value.getDate ())
const h = pad (value.getHours ()) const h = pad (value.getHours ())
const min = pad (value.getMinutes ()) const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }` return `${ y }-${ m }-${ day }T${ h }:${ min }${seconds ? `:${pad (value.getSeconds ())}` : ''}`
} }
@@ -37,15 +37,17 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
onChange?: (isoUTC: string | null) => void onChange?: (isoUTC: string | null) => void
className?: string className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void onBlur?: (ev: FocusEvent<HTMLInputElement>) => void
invalid?: boolean } invalid?: boolean
precision?: 'minute' | 'second' }
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid, ...rest }) => { const DateTimeField: FC<Props> = props => {
const { value, onChange, className, onBlur, invalid, precision = 'minute', ...rest } = props
const [local, setLocal] = useState ('') const [local, setLocal] = useState ('')
useEffect (() => { useEffect (() => {
setLocal (value ? toDateTimeLocalValue (new Date (value)) : '') setLocal (value ? toDateTimeLocalValue (new Date (value), precision === 'second') : '')
}, [value]) }, [value, precision])
return ( return (
<input <input
@@ -61,13 +63,15 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
'focus:ring-2 focus:ring-blue-200']), 'focus:ring-2 focus:ring-blue-200']),
className)} className)}
type="datetime-local" type="datetime-local"
step={60} step={precision === 'second' ? 1 : 60}
value={local} value={local}
aria-invalid={invalid} aria-invalid={invalid}
onChange={ev => { onChange={ev => {
const v = ev.target.value const v = ev.target.value
setLocal (v) setLocal (v)
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null) const iso = v ? (precision === 'second'
? new Date (v).toISOString () : toMinutePrecisionIsoUtc (v)) : null
onChange?.(iso)
}} }}
onBlur={onBlur}/>) onBlur={onBlur}/>)
} }
+35
ファイルの表示
@@ -0,0 +1,35 @@
import { useId, useRef } from 'react'
import { CircleHelp } from 'lucide-react'
import type { ReactNode } from 'react'
const HelpPopover = ({ label, children }: {
label: { text: string; language: string }; children: ReactNode },
) => {
const id = useId ()
const trigger = useRef<HTMLButtonElement> (null)
const panel = useRef<HTMLDivElement> (null)
return <>
<button ref={trigger} type="button" popoverTarget={id} aria-label={label.text}
lang={label.language} className="inline-flex align-middle"
onClick={() => {
const rect = trigger.current?.getBoundingClientRect ()
if (panel.current != null && rect != null)
{
panel.current.style.top = `${rect.bottom + 4}px`
panel.current.style.left = `${Math.min (rect.left, window.innerWidth - 288)}px`
}
}}><CircleHelp size={14}/></button>
<div ref={panel} id={id} popover="auto" tabIndex={-1}
role="dialog" aria-label={label.text} lang={label.language}
onToggle={event => {
if (event.newState === 'open') panel.current?.focus ()
}} className="fixed m-0 w-72 max-w-[90vw] rounded border bg-white p-3
text-sm text-slate-900 shadow-lg dark:border-slate-600 dark:bg-slate-900
dark:text-slate-100">{children}</div>
</>
}
export default HelpPopover
+7 -4
ファイルの表示
@@ -12,6 +12,7 @@ import type { Tag } from '@/types'
type Props = { type Props = {
includeNico?: boolean includeNico?: boolean
includeUnused?: boolean
describedBy?: string describedBy?: string
invalid?: boolean invalid?: boolean
value: string value: string
@@ -20,6 +21,7 @@ type Props = {
const TagInput: FC<Props> = ({ const TagInput: FC<Props> = ({
includeNico = false, includeNico = false,
includeUnused = false,
describedBy, describedBy,
invalid, invalid,
value, value,
@@ -57,11 +59,11 @@ const TagInput: FC<Props> = ({
{ {
const tags = await apiGet<Tag[]> ('/tags/autocomplete', { const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, locale, signal: controller.signal,
params: { q: request.query, nico: includeNico ? '1' : '0' } }) params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
if (active) if (active)
{ {
setResult ({ locale, includeNico, request, setResult ({ locale, includeNico, request,
tags: tags.filter (tag => tag.postCount > 0) }) tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
} }
} }
catch catch
@@ -74,7 +76,7 @@ const TagInput: FC<Props> = ({
active = false active = false
controller.abort () controller.abort ()
} }
}, [request, locale, includeNico]) }, [request, locale, includeNico, includeUnused])
useEffect (() => { useEffect (() => {
setActiveIndex (-1) setActiveIndex (-1)
@@ -85,7 +87,8 @@ const TagInput: FC<Props> = ({
// TODO: TagSearch からのコピペのため,共通化を考へる. // TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => { const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ') const parts = value?.split (' ')
parts[parts.length - 1] = tag.name const negative = /^not:/i.test (parts[parts.length - 1])
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
setValue (parts.join (' ') + ' ') setValue (parts.join (' ') + ' ')
setRequest ({ query: '' }) setRequest ({ query: '' })
setActiveIndex (-1) setActiveIndex (-1)
+90
ファイルの表示
@@ -0,0 +1,90 @@
import { useState } from 'react'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import { countsFieldLabel, countsFilterFields, countsOperators, readCountsJSON }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource } from '@/lib/counts'
import type { CountsCondition } from '@/lib/countsDefinitions'
const ConditionEditor = ({ resource, initial, save }: {
resource: CountsResource; initial?: CountsCondition
save: (condition: CountsCondition) => void },
) => {
const { label, message } = useUITranslation ('counts')
const fields = countsFilterFields (resource)
const [field, setField] = useState (initial?.field ?? 'resource_id')
const [operator, setOperator] = useState (initial?.operator ?? 'eq')
return <div className="space-y-2">
<select value={field} aria-label={message ('field').text} className={inputClass ()}
onChange={event => { setField (event.target.value); setOperator ('eq') }}>
{Object.keys (fields).filter (key => fields[key] !== 'json').map (key => (
<option key={key} value={key}>{message (countsFieldLabel (key)).text}</option>))}
</select>
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
onChange={event => setOperator (event.target.value)}>
{countsOperators[fields[field]].map (key => <option key={key} value={key}>
{message (`operators.${key}`).text}</option>)}
</select>
<Button type="button" onClick={() => save ({ ...initial, field, operator })}>
{label (initial == null ? 'add' : 'shared:actions.edit')}</Button>
</div>
}
const CountsAdditionalConditions = ({ resource, params, update }: {
resource: CountsResource; params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const dialogue = useDialogue ()
const conditions = readCountsJSON<CountsCondition[]> (params, 'conditions', [])
const write = (next: CountsCondition[]) => update ('conditions', JSON.stringify (next))
const edit = (index?: number) => {
void dialogue.form ({ title: label ('additionalConditions'), body: controls => (
<ConditionEditor resource={resource} initial={index == null ? undefined : conditions[index]}
save={condition => {
write (index == null ? [...conditions, condition]
: conditions.map ((item, position) => position === index ? condition : item))
controls.close ()
}}/>) })
}
const patch = (index: number, change: Partial<CountsCondition>) => write (
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
return <fieldset className="space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')}
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
{label ('add')}</Button></legend>
{conditions.map ((condition, index) => {
const datetime = countsFilterFields (resource)[condition.field] === 'datetime'
return <div key={index} className="flex flex-wrap items-center gap-1">
<button type="button" onClick={() => edit (index)}>
{label (countsFieldLabel (condition.field))} · {label (`operators.${condition.operator}`)}
</button>
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
.map (key => datetime ? <DateTimeField precision="second" key={key}
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={message (key === 'end' ? 'through' : 'value').text}
onChange={value => patch (index, { [key]: value ?? '' })}/> : (
<input key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')}
aria-label={message ('value').text}
onChange={event => patch (index, { [key]: event.target.value })}/>))}
<label className="inline-flex items-center gap-1">
<input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/>
{label ('blank')}</label>
<button type="button" aria-label={message ('remove').text}
onClick={() => write (conditions.filter ((_, position) => index !== position))}>×</button>
</div>
})}
</fieldset>
}
export default CountsAdditionalConditions
+125
ファイルの表示
@@ -0,0 +1,125 @@
import { DndContext, MouseSensor, TouchSensor, closestCenter,
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { countsAxisFields, countsFilterFields, countsGrains, readCountsJSON }
from '@/lib/countsDefinitions'
import { cn, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
const AxisItem = ({ id, name, children }: { id: string; name: string; children: ReactNode }) => {
const drag = useDraggable ({ id })
const drop = useDroppable ({ id })
return <div ref={drop.setNodeRef} className={cn (
'flex flex-wrap items-center gap-1 rounded border p-1 dark:border-slate-600',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
<button ref={drag.setNodeRef} type="button" {...drag.listeners} {...drag.attributes}
className="cursor-grab touch-none" aria-label={name}>
<span aria-hidden>⠿</span>
</button>{children}
</div>
}
const AxisZone = ({ id, children }: { id: string; children: ReactNode }) => {
const drop = useDroppable ({ id })
return <div ref={drop.setNodeRef} className={cn (
'flex min-h-12 flex-wrap items-start gap-1 rounded border p-1 dark:border-slate-600',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
{children}
</div>
}
const CountsAxes = ({ resource, params, update }: {
resource: CountsResource; params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const history = params.get ('definition') === 'history'
const available = countsAxisFields (resource, history)
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
const cols = readCountsJSON<CountsAxis[]> (params, 'cols', [])
const groups = { rows, cols }
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
const place = (field: string, target: string, position?: string) => {
for (const key of ['rows', 'cols'] as const)
{
const existing = [...rows, ...cols].find (axis => axis.field === field)
const next = groups[key].filter (axis => axis.field !== field)
if (target === key)
{
const index = position == null ? next.length : next.findIndex (
axis => axis.field === position)
next.splice (index < 0 ? next.length : index, 0, existing ?? {
field, ...(countsFilterFields (resource)[field] === 'datetime'
? { grain: 'month' } : { }) })
}
update (key, JSON.stringify (next))
}
}
return <DndContext sensors={sensors} collisionDetection={closestCenter}
accessibility={{ announcements: {
onDragStart: () => undefined, onDragOver: () => undefined,
onDragMove: () => undefined, onDragEnd: () => undefined,
onDragCancel: () => undefined },
screenReaderInstructions: { draggable: message ('axis').text } }}
onDragEnd={({ active, over }) => {
if (over == null) return
const [, field] = String (active.id).split ('/')
const [zone, position] = String (over.id).split ('/')
place (field, zone, position)
}}>
<div className="space-y-2">
<fieldset><legend>{label ('axisItems')}</legend><AxisZone id="available">
{available.filter (field => ![...rows, ...cols].some (
axis => axis.field === field.key)).map (field => (
<AxisItem key={field.key} id={`available/${field.key}`} name={message (field.label).text}>
{label (field.label)}
{(['rows', 'cols'] as const).map (target => <button key={target} type="button"
aria-label={message ('axisPlace', {
field: message (field.label).text, target: message (target).text }).text}
onClick={() => place (field.key, target)}>{label (target)}</button>)}
</AxisItem>))}
</AxisZone></fieldset>
{(['rows', 'cols'] as const).map (zone => <fieldset key={zone}>
<legend>{label (zone)}</legend><AxisZone id={zone}>
{groups[zone].map ((axis, index) => {
const field = available.find (field => field.key === axis.field)
return <AxisItem key={axis.field} id={`${zone}/${axis.field}`}
name={field == null ? message ('axis').text : message (field.label).text}>
{field == null ? null : label (field.label)}
{countsFilterFields (resource)[axis.field] === 'datetime' && <select
className={inputClass (false, 'w-auto py-0')}
value={axis.grain ?? 'month'} aria-label={message ('grain').text}
onChange={event => update (zone, JSON.stringify (groups[zone].map (
item => item === axis ? { ...item, grain: event.target.value } : item)))}>
{countsGrains.map (grain => <option key={grain} value={grain}>
{message (`grains.${grain}`).text}</option>)}
</select>}
<button type="button" aria-label={message ('reorder', {
column: field == null ? '' : message (field.label).text }).text}
disabled={index === 0}
onClick={() => place (axis.field, zone, groups[zone][index - 1]?.field)}>↑</button>
<button type="button" onClick={() => place (
axis.field, zone === 'rows' ? 'cols' : 'rows')}>
{label (zone === 'rows' ? 'cols' : 'rows')}</button>
<button type="button" aria-label={message ('remove').text}
onClick={() => place (axis.field, 'available')}>×</button>
</AxisItem>
})}
</AxisZone>
</fieldset>)}
</div>
</DndContext>
}
export default CountsAxes
+95 -177
ファイルの表示
@@ -1,14 +1,15 @@
import { useEffect, useId, useState } from 'react' import { useEffect, useId, useState } from 'react'
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
import CountsAxes from '@/components/counts/CountsAxes'
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 HelpPopover from '@/components/common/HelpPopover'
import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { isCountsState, normaliseCountsParams } from '@/lib/counts' import { normaliseCountsParams } from '@/lib/counts'
import { countsSummaryFields } from '@/lib/countsState'
import { fetchTagByName } from '@/lib/tags'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -18,17 +19,13 @@ import type { CountsResource } from '@/lib/counts'
const CountsControls = ( const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: { { resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
resource: CountsResource resource: CountsResource; applied: URLSearchParams; open: boolean
applied: URLSearchParams setOpen: (open: boolean) => void
open: boolean execute: (params: URLSearchParams) => void
setOpen: (open: boolean) => void displayJSON: (enabled: boolean) => void
execute: (params: URLSearchParams) => void reorder: (value: string) => void; actions?: ReactNode },
displayJSON: (enabled: boolean) => void
reorder: (value: string) => void
actions?: ReactNode },
) => { ) => {
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const id = useId () const id = useId ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied)) const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const order = applied.get ('order') const order = applied.get ('order')
@@ -38,175 +35,96 @@ const CountsControls = (
const next = new URLSearchParams (previous) const next = new URLSearchParams (previous)
for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON })) for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON }))
{ {
if (value == null) next.delete (key) if (value == null) next.delete (key)
else next.set (key, value) else next.set (key, value)
} }
return next return next
}) })
}, [order, nestedJSON]) }, [order, nestedJSON])
const [error, setError] = useState (false)
const [pending, setPending] = useState (false)
const mode = draft.get ('mode') ?? 'summary' const mode = draft.get ('mode') ?? 'summary'
const state = isCountsState (draft) const history = draft.get ('definition') === 'history'
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user'] const update = (key: string, value: string) => setDraft (previous => {
const update = (key: string, value: string) => { const next = new URLSearchParams (previous)
setDraft (previous => { if (['mode', 'rows', 'cols'].includes (key)) next.delete ('sort')
const next = new URLSearchParams (previous) next.set (key, value)
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort') return next
if (key === 'axis' || key === 'basis') next.delete ('summary_columns') })
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags') return <form className="shrink-0" onSubmit={event => {
next.set ('axis', 'month') event.preventDefault ()
if (value || ['columns', 'children', 'summary_columns'].includes (key)) const next = new URLSearchParams (draft)
next.set (key, value) next.delete ('page')
else next.delete (key) next.delete ('execution_at')
return next execute (normaliseCountsParams (resource, next))
}) }}>
} <div className="flex flex-wrap items-center gap-2 border-b pb-1 dark:border-slate-700">
const submit = async () => { {history ? <>
setError (false) {['from', 'through'].map (key => <label key={key}
setPending (true) className="flex flex-wrap items-center gap-1 text-sm">
try {label (key)}<DateTimeField precision="second" value={draft.get (key) ?? ''}
{ aria-label={message (key).text} onChange={value => update (key, value ?? '')}/>
const next = new URLSearchParams (draft) </label>)}
for (const key of resource === 'posts' ? ['tag'] : ['resource_id']) </> : <label className="flex flex-wrap items-center gap-1 text-sm">
{ {label ('referenceAt')}<HelpPopover label={message ('referenceAt')}>
const value = next.get (key)?.trim () ?? '' {label ('referenceHelp')}</HelpPopover>
if (value && !/^[1-9][0-9]*$/.test (value)) <DateTimeField precision="second" value={draft.get ('at') ?? ''}
{ aria-label={message ('referenceAt').text}
if (state && next.get ('kind') === 'external') throw new Error () onChange={value => update ('at', value ?? '')}/>
const tag = await fetchTagByName (value, locale) </label>}
if (tag == null) </div>
throw new Error () <div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
next.set (key, String (tag.id)) <Button type="submit" size="sm">{label ('run')}</Button>
} <Button type="button" size="sm" variant="outline" aria-expanded={open}
} aria-controls={id} onClick={() => setOpen (!open)}>
next.delete ('page') {label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span>
execute (normaliseCountsParams (resource, next)) </Button>
} <select className={inputClass (false, 'w-auto py-1')}
catch aria-label={message ('mode').text} lang={message ('mode').language}
{ value={mode} onChange={event => update ('mode', event.target.value)}>
setError (true) {['summary', 'details'].map (value => <option key={value} value={value}>
} {message (value).text}</option>)}
finally </select>{actions}
{ </div>
setPending (false) <div id={id} hidden={!open} className="max-h-[35dvh] overflow-y-auto overscroll-contain
} border-b py-2 dark:border-slate-700 md:max-h-[40dvh]">
} <div className="grid gap-3 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
return ( {mode === 'summary' ? <CountsAxes resource={resource} params={draft} update={update}/>
<form className="shrink-0" onSubmit={event => { : <>
event.preventDefault () <legend className="mb-1 font-semibold">{label ('output')}</legend>
void submit () <label className="mb-1 flex items-center gap-1">
}}> <input type="checkbox" checked={draft.get ('nested_json') === '1'}
<div className="flex flex-wrap items-center gap-2 border-b pb-2 dark:border-slate-700"> onChange={event => {
<Button type="submit" size="sm" disabled={pending}>{label ('run')}</Button> update ('nested_json', event.target.checked ? '1' : '0')
<Button type="button" size="sm" variant="outline" aria-expanded={open} displayJSON (event.target.checked)
aria-controls={id} onClick={() => setOpen (!open)}> }}/>{label ('nested.asJSON')}</label>
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span> <CountsFieldTree resource={resource} params={draft} update={(key, value) => {
</Button> update (key, value)
<select className={inputClass (false, 'w-auto py-1')} if (key === 'order') reorder (value)
aria-label={message ('mode').text} lang={message ('mode').language} }}/>
value={mode} onChange={event => update ('mode', event.target.value)}> </>}
{['summary', 'details'].map (value => ( </fieldset>
<option key={value} value={value} lang={message (value).language}> <fieldset className="min-w-0 space-y-2" data-counts-filters>
{message (value).text}</option>))} <legend className="font-semibold">{label ('filters')}</legend>
</select> <div className="flex flex-wrap gap-2">
{actions} {['resource_id', 'user_id', 'limit'].map (key => <FormField key={key}
</div> label={label (key === 'resource_id' ? 'columns.resourceId'
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>} : (key === 'user_id' ? 'fields.actor' : 'fields.limit'))}>
<div id={id} hidden={!open} {() => <input value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2 aria-label={message (key === 'resource_id' ? 'columns.resourceId'
dark:border-slate-700 md:max-h-[45dvh]"> : (key === 'user_id' ? 'fields.actor' : 'fields.limit')).text}
<div className="grid gap-4 md:grid-cols-2"> className={inputClass (false, 'w-28 py-1')}
<fieldset className="min-w-0" data-counts-output> onChange={event => update (key, event.target.value)}/>}
<legend className="mb-2 font-semibold">{label ('output')}</legend> </FormField>)}
<label className="mb-2 flex items-center gap-1"> </div>
<input type="checkbox" checked={draft.get ('nested_json') === '1'} {resource === 'posts' && <FormField label={label ('columns.tags')}>
onChange={event => { {() => <TagInput includeNico includeUnused value={draft.get ('tags') ?? ''}
update ('nested_json', event.target.checked ? '1' : '0') setValue={value => update ('tags', value)}/>}
displayJSON (event.target.checked) </FormField>}
}}/> <CountsAdditionalConditions resource={resource} params={draft} update={update}/>
{label ('nested.asJSON')} </fieldset>
</label> </div>
<FormField label={label ('measure')}> </div>
{() => <select value={state ? 'state' : 'history'} </form>
className={inputClass (false, 'w-auto py-1')}
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')}
<select className={inputClass (false, 'w-auto py-1')}
value={draft.get ('axis')
?? ((state && resource === 'posts') ? 'tags' : 'month')}
aria-label={message ('axis').text} lang={message ('axis').language}
onChange={event => update ('axis', event.target.value)}>
{axes.map (axis => <option key={axis} value={axis}
lang={message (`axes.${axis}`).language}>
{message (`axes.${axis}`).text}</option>)}
</select>
</label>}
<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)
if (key === 'order') reorder (value)
}}/>
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
<FormField label={label ('state.at')}>
{() => <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'}
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
{label ('latestOnly')}
</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">
{['from', 'before'].map (key => (
<FormField key={key} label={label (`fields.${key}`)}>
{() => <DateTimeField value={draft.get (key) ?? ''}
aria-label={message (`fields.${key}`).text}
lang={message (`fields.${key}`).language}
onChange={value => update (key, value ?? '')}/>}
</FormField>))}
</div>
<div className="grid gap-2 md:grid-cols-2">
{[['resource_id', resource === 'posts' ? 'post' : 'tag'], ['user_id', 'actor'],
...(resource === 'posts' ? [['tag', 'tag']] : []), ['limit', 'limit']]
.map (([key, field]) => (
<FormField key={key} label={label (`fields.${field}`)}>
{() => <input aria-label={message (`fields.${field}`).text}
lang={message (`fields.${field}`).language}
type={key === 'limit' ? 'number' : 'text'} min={1} max={100}
value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
onChange={event => update (key, event.target.value)}
className={inputClass (false, 'py-1')}/>}
</FormField>))}
</div>
</fieldset>
</div>
</div>
</form>)
} }
-2
ファイルの表示
@@ -124,7 +124,6 @@ const CountsFieldTree = (
params: URLSearchParams params: URLSearchParams
update: (key: string, value: string) => void }, update: (key: string, value: string) => void },
) => { ) => {
const { label } = useUITranslation ('counts')
const fields = suppliedFields ?? fieldsForCounts (resource) const fields = suppliedFields ?? fieldsForCounts (resource)
const roots = suppliedFields == null ? selectedCountColumns (resource, params) const roots = suppliedFields == null ? selectedCountColumns (resource, params)
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key) : selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
@@ -151,7 +150,6 @@ const CountsFieldTree = (
<> <>
<FieldGroup fields={fields} path={parent} params={params} enabled selected={selected} <FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
select={select} update={update}/> select={select} update={update}/>
{suppliedFields == null && <p className="mt-2 text-xs">{label ('nested.selection')}</p>}
</>) </>)
} }
+4
ファイルの表示
@@ -34,6 +34,10 @@ const CountsNestedTable = (
const scalar = (row: CountRecord, field: string) => { const scalar = (row: CountRecord, field: string) => {
const value = row[field] const value = row[field]
if (value == null) return null if (value == null) return null
if (field === 'tagId' || field === 'tagGroupId')
return <PrefetchLink to={field === 'tagId'
? `/tags/${encodeURIComponent (String (value))}` : '/wiki'}>
{String (value)}</PrefetchLink>
if (field === 'id') if (field === 'id')
{ {
if (row.kind === 'external') return String (value) if (row.kind === 'external') return String (value)
+11 -5
ファイルの表示
@@ -7,12 +7,12 @@ import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts, import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts' readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay' import { countsCellCopy, countsCellText, countsImageURL, countsNestedRows } from '@/lib/countsDisplay'
import useDialogue from '@/lib/dialogues/useDialogue' import useDialogue from '@/lib/dialogues/useDialogue'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { CountColumn, CountRecord, CountsResource, import type { CountColumn, CountsResource,
CountsResult, CountsVersion } from '@/lib/counts' CountsResult, CountsVersion } from '@/lib/counts'
@@ -34,17 +34,23 @@ const CountsResults = (
update ('sort', writeCountSort (changeCountSort (sorts, key, additive))) update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
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, params)
if (field.children != null) if (field.children != null)
{ {
return <CountsNestedTable rows={row[column as keyof CountsVersion] as CountRecord[] ?? []} return <CountsNestedTable rows={countsNestedRows (row, column)}
fields={field.children} path={column} resource={resource} fields={field.children} path={column} resource={resource}
title={message (field.label)} params={params} update={update}/> title={message (field.label)} params={params} update={update}/>
} }
switch (column) switch (column)
{ {
case 'resource_id': case 'resource_id':
return <PrefetchLink to={`/${resource}/${encodeURIComponent (row.resourceId)}`}> if (resource === 'nico_tags') return <PrefetchLink to="/nico/tags">
{row.resourceId}</PrefetchLink>
if (resource === 'tag_groups') return <PrefetchLink
to={`/wiki/${encodeURIComponent (`タググループ:${row.name ?? ''}`)}`}>
{row.resourceId}</PrefetchLink>
return <PrefetchLink to={`/${resource === 'tag_descriptions' ? 'tags' : resource}/${
encodeURIComponent (row.resourceId)}`}>
{row.resourceId}</PrefetchLink> {row.resourceId}</PrefetchLink>
case 'thumbnail': case 'thumbnail':
{ {
+82 -46
ファイルの表示
@@ -1,62 +1,98 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable' import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader' import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts' import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsSummaryCell, countsSummaryDrill, countsSummaryRows, import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
selectedCountsSummaryFields } from '@/lib/countsState'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource, CountsResult } from '@/lib/counts' import type { CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
const CountsSummaryResults = ( const CountsSummaryResults = (
{ resource, data, params, pending, update }: { { resource, data, params, pending, update }: {
resource: CountsResource resource: CountsResource; data: CountsResult; params: URLSearchParams
data: CountsResult pending: boolean; update: (key: string, value: string) => void },
params: URLSearchParams
pending: boolean
update: (key: string, value: string) => void },
) => { ) => {
const locale = useLocale ()
const { label, message } = useUITranslation ('counts') const { label, message } = useUITranslation ('counts')
const fields = selectedCountsSummaryFields (resource, params) const rows = data.rows ?? []
const rows = countsSummaryRows (data) const cols = data.cols ?? []
const sorts = readCountSort (params.get ('sort') ?? ( const axes = [...rows, ...cols]
params.get ('axis') === 'tags' ? 'count:desc' : 'group:asc')) const cells = data.cells ?? []
return ( const rowKeys = [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))]
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}> const columnKeys = data.columnValues?.map (values => JSON.stringify (values))
{fields.length === 0 ? <p>{label ('nested.noColumns')}</p> : ( ?? [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (rows.length))))]
<CountsSelectableTable family={`summary_${params.get ('axis')}`} if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]')
columns={fields.map (field => field.key)} const sorts = readCountSort (params.get ('sort'))
identity={`${countsRequestParams (params)}:${fields.map (field => field.key)}`} const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
className="text-left text-sm"> if (value == null) return ''
<thead className="sticky top-0 z-20"><tr> const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
{fields.map (field => ( if (logical != null) return logical
<CountsSortHeader key={field.key} field={field.key} if (['tags', 'user', 'language_code', 'source_language_code'].includes (axis.field))
label={message (field.label)} return message ('notRegistered').text
sorts={sorts} change={(key, additive) => update ( if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))} if (axis.field === 'event_type') return message (`events.${value}`).text
</tr></thead> if (axis.field === 'category' || axis.field === 'tag_category')
<tbody>{rows.map (row => ( return message (`common:categories.${value}`).text
<tr key={String (row.key)}> return String (value)
{fields.map (field => { }
const value = countsSummaryCell (row, field.key, locale) const drill = (values: (string | number | null)[]) => {
const content = value.language == null ? value.text : ( const next = new URLSearchParams (params)
<LocalisedText language={value.language}>{value.text}</LocalisedText>) const existing = readCountsJSON<unknown[]> (params, 'drill', [])
return <td key={field.key} data-counts-copy={value.text} next.set ('drill', JSON.stringify ([...existing,
className="break-words border-b px-2 py-1 dark:border-slate-700"> ...axes.map ((axis, index) => ({ ...axis, value: values[index] }))]))
{(['count', 'group'].includes (field.key) && row.count != null) next.set ('mode', 'details')
? <PrefetchLink to={countsSummaryDrill (resource, params, row)}> next.delete ('sort')
{content}</PrefetchLink> next.delete ('page')
: content} return `/counts/${resource}?${next}`
</td> }
})} const keys = [...rows.map ((_, index) => `a${index}`),
</tr>))}</tbody> ...columnKeys.map ((_, index) => `cell${index}`)]
</CountsSelectableTable>)} return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{rows.length === 0 && <p className="p-2">{label ('empty')}</p>} <CountsSelectableTable family={`pivot_${resource}`} columns={keys}
</div>) identity={`${countsRequestParams (params)}:${keys}`} className="text-left text-sm">
<thead className="sticky top-0 z-20"><tr>
{rows.map ((axis, index) => <CountsSortHeader key={axis.field} field={`a${index}`}
label={message (countsFieldLabel (axis.field))} sorts={sorts}
change={(key, additive) => update ('sort', writeCountSort (
changeCountSort (sorts, key, additive)))}/>)}
{columnKeys.map ((key, index) => {
const values = JSON.parse (key) as (string | number | null)[]
const name = values.map ((value, position) =>
`${message (countsFieldLabel (cols[position].field)).text}: ${
display (value, cols[position], rows.length + position)}`).join (' / ')
const sortKey = cols.length === 0 ? 'count' : `measure_${[
...new TextEncoder ().encode (key)].map (byte =>
byte.toString (16).padStart (2, '0')).join ('')}`
return <CountsSortHeader key={key} field={`cell${index}`}
label={{ text: name || message ('count').text, language: message ('count').language }}
sorts={sorts.map (sort => sort.key === sortKey
? { ...sort, key: `cell${index}` } : sort)}
change={(_, additive) => update ('sort', writeCountSort (
changeCountSort (sorts, sortKey, additive)))}/>
})}
</tr></thead>
<tbody>{rowKeys.map (rowKey => {
const rowValues = JSON.parse (rowKey) as (string | number | null)[]
return <tr key={rowKey}>
{rowValues.map ((value, index) => <td key={index}
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700"
data-counts-copy={display (value, rows[index], index)}>
{display (value, rows[index], index)}</td>)}
{columnKeys.map (columnKey => {
const columnValues = JSON.parse (columnKey) as (string | number | null)[]
const values = [...rowValues, ...columnValues]
const cell = cells.find (cell => JSON.stringify (cell.values) === JSON.stringify (values))
return <td key={columnKey} data-counts-copy={String (cell?.count ?? 0)}
className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (values)}>{cell?.count ?? 0}</PrefetchLink>
</td>
})}
</tr>
})}</tbody>
</CountsSelectableTable>
{cells.length === 0 && <p>{label ('empty')}</p>}
</div>
} }
+25
ファイルの表示
@@ -199,3 +199,28 @@ export const apiDelete = async <T = void> (
export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> => export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> =>
axios.isAxiosError (err) axios.isAxiosError (err)
// Native downloads let the browser stream to disk without buffering a Blob in app memory.
export const apiDownload = async (
path: string,
body: Record<string, string>,
locale: LocaleCode,
): Promise<void> => {
const token = await csrfToken ()
const form = document.createElement ('form')
form.method = 'post'
form.action = `${API_BASE_URL}${path}?${new URLSearchParams ({ locale })}`
form.hidden = true
for (const [name, value] of Object.entries ({ ...body, authenticity_token: token }))
{
const input = document.createElement ('input')
input.type = 'hidden'
input.name = name
input.value = value
form.append (input)
}
document.body.append (form)
form.submit ()
form.remove ()
}
+61 -34
ファイルの表示
@@ -1,9 +1,11 @@
import { apiGet, apiPost } from '@/lib/api' import { countsAttributes, countsFieldLabel } from '@/lib/countsDefinitions'
import { apiDownload, apiGet } 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'
export type CountsResource = 'posts' | 'tags' export type CountsResource = 'posts' | 'tags' | 'nico_tags'
| 'tag_descriptions' | 'tag_groups' | 'materials'
export type CountsName = TagNameSnapshot & { export type CountsName = TagNameSnapshot & {
languageCode: string; languageName?: string | null; scriptName?: string | null } languageCode: string; languageName?: string | null; scriptName?: string | null }
export type CountsTag = { export type CountsTag = {
@@ -17,6 +19,8 @@ export type CountsTag = {
sections: { beginMs: number; endMs: number | null }[] sections: { beginMs: number; endMs: number | null }[]
names: CountsName[] } names: CountsName[] }
export type CountsVersion = { export type CountsVersion = {
[key: string]: unknown
eventType?: string
id: number id: number
resourceId: number resourceId: number
versionNo: number versionNo: number
@@ -51,6 +55,12 @@ export type CountsStateTotals = {
deprecatedTags: number | null deprecatedTags: number | null
externalAssignments: number | null } externalAssignments: number | null }
export type CountsResult = { export type CountsResult = {
executionAt?: string
columnValues?: (string | number | null)[][]
cells?: { values: (string | number | null)[]; count: number }[]
axisLabels?: Record<string, Record<string, string | null>>
rows?: { field: string; grain?: string }[]
cols?: { field: string; grain?: string }[]
count: number count: number
page: number page: number
limit: number limit: number
@@ -69,7 +79,8 @@ export type CountSort = { key: string; direction: 'asc' | 'desc' }
export type CountField = { key: string; label: string; children?: CountField[] } export type CountField = { key: string; label: string; children?: CountField[] }
export type CountRecord = Record<string, unknown> export type CountRecord = Record<string, unknown>
export const countColumns = { export const countColumns: Record<string, string> = {
event_type: 'counts:eventType',
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId', thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
version_no: 'history:version', is_latest: 'counts:columns.latest', version_no: 'history:version', is_latest: 'counts:columns.latest',
title: 'counts:columns.postTitle', url: 'counts:columns.postURL', title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
@@ -82,7 +93,7 @@ export const countColumns = {
category: 'common:categoryLabel', aliases: 'tags:aliases', category: 'common:categoryLabel', aliases: 'tags:aliases',
deprecated: 'counts:columns.deprecated', user: 'history:actor', deprecated: 'counts:columns.deprecated', user: 'history:actor',
created_at: 'counts:columns.changedAt' } created_at: 'counts:columns.changedAt' }
export type CountColumn = keyof typeof countColumns export type CountColumn = string
const aliasFields: CountField[] = [ const aliasFields: CountField[] = [
{ key: 'name', label: 'history:name' }, { key: 'name', label: 'history:name' },
@@ -93,7 +104,24 @@ const nameFields: CountField[] = [
{ key: 'name', label: 'history:name' }, { key: 'name', label: 'history:name' },
{ key: 'aliases', label: 'tags:aliases', children: aliasFields }, { key: 'aliases', label: 'tags:aliases', children: aliasFields },
{ key: 'autoGenerated', label: 'history:nameChanges.automatic' }] { key: 'autoGenerated', label: 'history:nameChanges.automatic' }]
const placementFields = (depth: number): CountField[] => [
{ key: 'sectionName', label: 'counts:attributes.section_name' },
{ key: 'description', label: 'counts:attributes.description' },
{ key: 'tagId', label: 'counts:attributes.tag_id' },
{ key: 'tagGroupId', label: 'counts:resources.tag_groups' },
...(depth > 0 ? [{ key: 'children', label: 'counts:attributes.children',
children: placementFields (depth - 1) }] : [])]
const nested: Partial<Record<CountColumn, CountField[]>> = { const nested: Partial<Record<CountColumn, CountField[]>> = {
placements: placementFields (4),
export_paths_json: [
{ key: 'profile', label: 'counts:attributes.profile' },
{ key: 'path', label: 'counts:attributes.path' }],
members: [
{ key: 'tagId', label: 'counts:columns.resourceId' },
{ key: 'tagGroupId', label: 'counts:resources.tag_groups' },
{ key: 'versionNo', label: 'history:version' }],
tags: [ tags: [
{ key: 'id', label: 'counts:columns.resourceId' }, { key: 'id', label: 'counts:columns.resourceId' },
{ key: 'versionNo', label: 'history:version' }, { key: 'versionNo', label: 'history:version' },
@@ -108,19 +136,22 @@ const nested: Partial<Record<CountColumn, CountField[]>> = {
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [ export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
...(resource === 'posts' ? ['thumbnail'] as const : []), ...(resource === 'posts' ? ['thumbnail'] : []),
'resource_id', 'version_no', 'is_latest', 'resource_id', 'version_no', 'is_latest',
...(resource === 'posts' ...(resource === 'posts'
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms', ? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'original_created_at', 'original_created_from', 'original_created_before'] as const 'original_created_at', 'original_created_from', 'original_created_before']
: ['name', 'names', 'category', 'aliases', 'parents', 'deprecated'] as const), : (resource === 'tags'
'user', 'created_at'] ? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
: Object.keys (countsAttributes[resource]))),
'event_type', 'user', 'created_at']
export const fieldsForCounts = (resource: CountsResource): CountField[] => export const fieldsForCounts = (resource: CountsResource): CountField[] =>
columnsForCounts (resource).map (key => ({ columnsForCounts (resource).map (key => ({
key, key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents' : countColumns[key], label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
: (countColumns[key] ?? countsFieldLabel (key)),
children: nested[key] })) children: nested[key] }))
@@ -185,7 +216,7 @@ export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
export const selectedCountChildren = (params: URLSearchParams): Set<string> => export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
new Set (params.has ('children') ? params.get ('children')!.split (',') : new Set (params.has ('children') ? params.get ('children')!.split (',') :
Object.entries (nested).flatMap (([key, fields]) => Object.entries (nested).flatMap (([key, fields]) =>
countChildPaths (fields, key))) countChildPaths (fields ?? [], key)))
export const readCountSort = (value: string | null): CountSort[] => export const readCountSort = (value: string | null): CountSort[] =>
@@ -234,24 +265,22 @@ export const normaliseCountsParams = (
source: URLSearchParams, source: URLSearchParams,
): URLSearchParams => { ): URLSearchParams => {
const params = new URLSearchParams (source) const params = new URLSearchParams (source)
params.delete ('event_type') if (!params.has ('definition'))
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
const legacy = params.get ('mode') const legacy = params.get ('mode')
if (['state', 'trend', 'snapshot'].includes (legacy ?? '')) if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
{
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary') 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') if (!params.has ('mode')) params.set ('mode', 'summary')
const axes = (resource === 'posts' && params.get ('basis') !== 'history') if (!params.has ('rows') && params.has ('axis'))
? ['tags', 'month', 'user'] : ['month', 'user'] {
if (!axes.includes (params.get ('axis') ?? '')) params.set ('axis', axes[0]) const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('axis')!
params.delete ('grain') params.set ('rows', JSON.stringify ([{
field, ...(field === 'created_at' ? { grain: 'month' } : { }) }]))
}
if (params.has ('before') && !params.has ('through'))
params.set ('through', params.get ('before')!)
for (const key of ['basis', 'axis', 'latest', 'grain', 'before', 'summary_columns'])
params.delete (key)
if (params.has ('columns')) if (params.has ('columns'))
{ {
const selected = new Set (params.get ('columns')!.split (',')) const selected = new Set (params.get ('columns')!.split (','))
@@ -263,14 +292,13 @@ export const normaliseCountsParams = (
export const isCountsState = (params: URLSearchParams): boolean => export const isCountsState = (params: URLSearchParams): boolean =>
params.get ('basis') !== 'history' params.get ('definition') !== '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) ? ['latest'] : ['kind']
for (const key of ['columns', 'summary_columns', 'children', 'child_sort', for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
'nested_json', 'order', 'basis', 'grain', ...hidden]) 'nested_json', 'order', 'basis', 'axis', 'latest', 'grain'])
result.delete (key) result.delete (key)
return result return result
} }
@@ -280,10 +308,9 @@ export const fetchCounts = (
resource: CountsResource, resource: CountsResource,
params: URLSearchParams, params: URLSearchParams,
locale: LocaleCode, locale: LocaleCode,
): Promise<CountsResult> => apiGet ( ): Promise<CountsResult> => apiGet ('/counts/query', {
isCountsState (params) ? '/counts/state' : `/counts/${resource}`, { locale, params: { ...Object.fromEntries (countsRequestParams (params)), resource } })
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
...(isCountsState (params) ? { resource } : { }) } })
export const exportCounts = ( export const exportCounts = (
@@ -294,10 +321,10 @@ export const exportCounts = (
columns: string[], columns: string[],
headers: string[], headers: string[],
labels: Record<string, string>, labels: Record<string, string>,
): Promise<Blob> => apiPost ('/counts/export', { ): Promise<void> => apiDownload ('/counts/export', {
...Object.fromEntries (params), resource, format, ...Object.fromEntries (params), resource, format,
columns: columns.join (','), headers: JSON.stringify (headers), columns: columns.join (','), headers: JSON.stringify (headers),
labels: JSON.stringify (labels) }, { locale, responseType: 'blob' }) labels: JSON.stringify (labels) }, locale)
+72
ファイルの表示
@@ -0,0 +1,72 @@
import type { CountField, CountsResource } from '@/lib/counts'
export type CountsAxis = { field: string; grain?: string }
export type CountsCondition = {
field: string; operator: string; value?: string; end?: string; blank?: boolean }
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json'
export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
export const countsGrains = ['year', 'quarter', 'month', 'week', 'day', 'hour']
export const countsOperators: Record<CountsFieldType, string[]> = {
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], json: [],
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'] }
export const countsAttributes: Record<CountsResource, Record<string, CountsFieldType>> = {
posts: { title: 'string', url: 'string', thumbnail_base: 'string', tags_json: 'json',
parent_post_ids: 'string', video_ms: 'integer', original_created_from: 'datetime',
original_created_before: 'datetime' },
tags: { name: 'string', names: 'json', category: 'enum', aliases: 'string',
parent_tag_ids: 'string', deprecated_at: 'datetime' },
nico_tags: { name: 'string', linked_tags: 'string' },
tag_descriptions: { language_code: 'enum', body: 'string', source_language_code: 'enum',
source_version_no: 'integer' },
tag_groups: { name: 'string', description: 'string', members: 'json', placements: 'json' },
materials: { url: 'string', parent_id: 'integer', tag_id: 'integer', tag_name: 'string',
tag_category: 'enum', discarded_at: 'datetime', updated_at: 'datetime',
updated_by_user_id: 'integer', export_paths_json: 'json', file_blob_id: 'integer',
file_filename: 'string', file_content_type: 'string', file_byte_size: 'integer',
file_checksum: 'string', file_sha256: 'string', source_kind: 'enum', source_uri: 'string',
source_path: 'string', source_file_id: 'string', normalized_source_key: 'string' } }
export const countsFilterFields = (
resource: CountsResource,
): Record<string, CountsFieldType> => ({
resource_id: 'integer' as const, version_no: 'integer' as const,
event_type: 'enum' as const, user: 'integer' as const, created_at: 'datetime' as const,
...countsAttributes[resource] })
export const countsFieldLabel = (key: string): string => {
const labels: Record<string, string> = {
resource_id: 'columns.resourceId', version_no: 'history:version',
user: 'history:actor', created_at: 'columns.changedAt', name: 'history:name',
title: 'columns.postTitle', url: 'columns.postURL', thumbnail_base: 'columns.thumbnailURL',
tags: 'columns.tags', tags_json: 'columns.tags', names: 'columns.names',
aliases: 'tags:aliases', category: 'common:categoryLabel',
language_code: 'columns.language', source_language_code: 'attributes.sourceLanguage',
changed_field: 'changedField' }
return labels[key] ?? `attributes.${key}`
}
export const countsAxisFields = (resource: CountsResource, history: boolean): CountField[] => [
...Object.entries (countsFilterFields (resource))
.filter (([, type]) => type !== 'json')
.map (([key]) => ({ key, label: countsFieldLabel (key) })),
...(resource === 'posts' ? [{ key: 'tags', label: 'columns.tags' }] : []),
...(history ? [{ key: 'changed_field', label: 'changedField' }] : [])]
export const readCountsJSON = <T,> (params: URLSearchParams, key: string, fallback: T): T => {
try
{
return JSON.parse (params.get (key) ?? JSON.stringify (fallback)) as T
}
catch
{
return fallback
}
}
+40 -8
ファイルの表示
@@ -12,7 +12,10 @@ export const countsCellText = (
row: CountsVersion, row: CountsVersion,
column: CountColumn, column: CountColumn,
locale: LocaleCode, locale: LocaleCode,
params = new URLSearchParams (),
): string => { ): string => {
const timezone = params.get ('timezone') ?? 'UTC'
const date = (value: string) => localisedDateString (value, locale, null, timezone)
switch (column) switch (column)
{ {
case 'resource_id': return String (row.resourceId) case 'resource_id': return String (row.resourceId)
@@ -28,6 +31,10 @@ export const countsCellText = (
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs) case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
case 'original_created_at': case 'original_created_at':
if (timezone !== 'Asia/Tokyo')
return translateUIMessage ('sections.range', 'counts', locale, {
begin: row.originalCreatedFrom == null ? '' : date (row.originalCreatedFrom),
end: row.originalCreatedBefore == null ? '' : date (row.originalCreatedBefore) }).text
return originalCreatedAtString ( return originalCreatedAtString (
row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale) row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale)
case 'original_created_from': case 'original_created_from':
@@ -35,10 +42,10 @@ export const countsCellText = (
{ {
const value = column === 'original_created_from' const value = column === 'original_created_from'
? row.originalCreatedFrom : row.originalCreatedBefore ? row.originalCreatedFrom : row.originalCreatedBefore
return value == null ? '' : localisedDateString (value, locale) return value == null ? '' : date (value)
} }
case 'deprecated': case 'deprecated':
return row.deprecated == null ? '' : localisedDateString (row.deprecated, locale) return row.deprecated == null ? '' : date (row.deprecated)
case 'user': case 'user':
if (row.createdByUser == null) if (row.createdByUser == null)
return translateUIMessage ('history:bot', 'counts', locale).text return translateUIMessage ('history:bot', 'counts', locale).text
@@ -46,12 +53,33 @@ export const countsCellText = (
? `${row.createdByUser.name} (#${row.createdByUser.id})` ? `${row.createdByUser.name} (#${row.createdByUser.id})`
: translateUIMessage ('history:anonymous', 'counts', locale, { : translateUIMessage ('history:anonymous', 'counts', locale, {
id: row.createdByUser.id }).text id: row.createdByUser.id }).text
case 'created_at': return localisedDateString (row.createdAt, locale) case 'created_at': return date (row.createdAt)
default: return '' default:
{
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
const value = row[key]
if (['discarded_at', 'updated_at'].includes (column) && typeof value === 'string')
return date (value)
if (column === 'event_type')
return translateUIMessage (`events.${value}`, 'counts', locale).text
if (column === 'language_code' || column === 'source_language_code')
return countsLogicalName ({ languageCode: value }, 'languageCode', locale).text
return value == null ? '' : (typeof value === 'object'
? JSON.stringify (value, null, 2) : String (value))
}
} }
} }
export const countsNestedRows = (row: CountsVersion, column: string): CountRecord[] => {
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
const value = row[key]
if (column === 'export_paths_json' && value != null && typeof value === 'object')
return Object.entries (value).map (([profile, path]) => ({ profile, path }))
return Array.isArray (value) ? value as CountRecord[] : []
}
export const countsCSVCell = ( export const countsCSVCell = (
row: CountsVersion, row: CountsVersion,
column: CountColumn, column: CountColumn,
@@ -62,10 +90,10 @@ export const countsCSVCell = (
const field = fieldsForCounts (resource).find (field => field.key === column)! const field = fieldsForCounts (resource).find (field => field.key === column)!
if (field.children != null) if (field.children != null)
{ {
const rows = row[column as keyof CountsVersion] as CountRecord[] ?? [] const rows = countsNestedRows (row, column)
return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale)) return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
} }
return countsCellText (row, column, locale) return countsCellText (row, column, locale, params)
} }
@@ -189,8 +217,8 @@ export const countsCellCopy = (
params: URLSearchParams, params: URLSearchParams,
locale: LocaleCode, locale: LocaleCode,
): string => { ): string => {
if (field.children == null) return countsCellText (row, field.key as CountColumn, locale) if (field.children == null) return countsCellText (row, field.key as CountColumn, locale, params)
const rows = row[field.key as keyof CountsVersion] as CountRecord[] ?? [] const rows = countsNestedRows (row, field.key)
return params.get ('nested_json') === '1' return params.get ('nested_json') === '1'
? JSON.stringify ( ? JSON.stringify (
countsNestedExport (rows, field.children, field.key, params, locale), null, 2) countsNestedExport (rows, field.children, field.key, params, locale), null, 2)
@@ -240,6 +268,10 @@ export const countsExportLabels = (
for (const key of ['ja', 'ain']) for (const key of ['ja', 'ain'])
labels[`language:${key}`] = translateUIMessage ( labels[`language:${key}`] = translateUIMessage (
`history:languageNames.${key}`, 'counts', locale).text `history:languageNames.${key}`, 'counts', locale).text
labels.id = translateUIMessage ('columns.resourceId', 'counts', locale).text
labels.name = translateUIMessage ('history:name', 'counts', locale).text
for (const event of ['create', 'update', 'discard', 'restore'])
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
return labels return labels
+15 -11
ファイルの表示
@@ -17,24 +17,26 @@ export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
type DatePrecision = 'month' | 'day' | 'hour' | 'minute' | 'second' | null type DatePrecision = 'month' | 'day' | 'hour' | 'minute' | 'second' | null
const dateFormatters = new Map<DatePrecision, Intl.DateTimeFormat> () const dateFormatters = new Map<string, Intl.DateTimeFormat> ()
let ainuDateFormatter: Intl.DateTimeFormat | null = null const ainuDateFormatters = new Map<string, Intl.DateTimeFormat> ()
const formatDateString = ( const formatDateString = (
date: Date, date: Date,
unknown: DatePrecision, unknown: DatePrecision,
locale: LocaleCode, locale: LocaleCode,
timeZone = 'Asia/Tokyo',
): string => { ): string => {
const key = `${timeZone}/${unknown}`
if (Number.isNaN (date.getTime ())) if (Number.isNaN (date.getTime ()))
return date.toString () return date.toString ()
if (locale === 'ja') if (locale === 'ja')
{ {
if (!dateFormatters.has (unknown)) if (!dateFormatters.has (key))
{ {
dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', { dateFormatters.set (key, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
era: 'long', year: 'numeric', era: 'long', year: 'numeric',
timeZone: 'Asia/Tokyo', timeZone,
month: unknown === 'month' ? undefined : 'long', month: unknown === 'month' ? undefined : 'long',
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric', day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short', weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
@@ -42,14 +44,15 @@ const formatDateString = (
minute: unknown == null || unknown === 'second' ? 'numeric' : undefined, minute: unknown == null || unknown === 'second' ? 'numeric' : undefined,
second: unknown == null ? 'numeric' : undefined })) second: unknown == null ? 'numeric' : undefined }))
} }
return dateFormatters.get (unknown)!.format (date) return dateFormatters.get (key)!.format (date)
} }
ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', { if (!ainuDateFormatters.has (timeZone))
ainuDateFormatters.set (timeZone, new Intl.DateTimeFormat ('en-CA', {
year: 'numeric', month: '2-digit', day: '2-digit', year: 'numeric', month: '2-digit', day: '2-digit',
timeZone: 'Asia/Tokyo', timeZone,
hour: '2-digit', minute: '2-digit', second: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23' }) hourCycle: 'h23' }))
const parts = ainuDateFormatter.formatToParts (date) const parts = ainuDateFormatters.get (timeZone)!.formatToParts (date)
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? '' const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
const omitDay = unknown != null && ['month', 'day'].includes (unknown) const omitDay = unknown != null && ['month', 'day'].includes (unknown)
const dateParts = [part ('year'), const dateParts = [part ('year'),
@@ -76,7 +79,8 @@ export const localisedDateString = (
value: string | Date, value: string | Date,
locale: LocaleCode, locale: LocaleCode,
unknown: DatePrecision = null, unknown: DatePrecision = null,
): string => dateString (value, unknown, locale) timeZone?: string,
): string => formatDateString (toDate (value), unknown, locale, timeZone)
export const originalCreatedAtString = ( export const originalCreatedAtString = (
+101 -3
ファイルの表示
@@ -1,9 +1,13 @@
{ {
"titles": { "titles": {
"posts": "投稿の集計・明細", "posts": "広場の集計・明細",
"tags": "タグの集計・明細" "tags": "タグの集計・明細",
"nico_tags": "ニコニコタグの集計・明細",
"tag_descriptions": "タグ説明の集計・明細",
"tag_groups": "タググループの集計・明細",
"materials": "素材の集計・明細"
}, },
"target": "集計対象", "target": "集計定義",
"run": "集計実行", "run": "集計実行",
"conditions": "集計条件", "conditions": "集計条件",
"output": "出力項目", "output": "出力項目",
@@ -119,5 +123,99 @@
"exportFormats": { "exportFormats": {
"json": "JSON", "json": "JSON",
"csv": "CSV" "csv": "CSV"
},
"resources": {
"posts": "広場",
"tags": "タグ",
"nico_tags": "ニコニコタグ",
"tag_descriptions": "タグ説明",
"tag_groups": "タググループ",
"materials": "素材"
},
"latest": "最新",
"history": "履歴",
"referenceAt": "基準日時",
"referenceHelp": "指定した日時における各データの最新版を集計・表示します。空欄の場合は現在の最新版を使用します。",
"from": "開始(以上)",
"through": "終了(以下)",
"axisItems": "項目一覧",
"rows": "行",
"cols": "列",
"axisPlace": "{{field}}を{{target}}に配置",
"field": "項目",
"operator": "演算子",
"add": "追加",
"remove": "削除",
"value": "値",
"blank": "空欄",
"additionalConditions": "追加条件",
"changedField": "変更項目",
"eventType": "異動区分",
"count": "件数",
"recordTotal": "総件数:{{count}}",
"grain": "粒度",
"grains": {
"year": "年",
"quarter": "四半期",
"month": "月",
"week": "週",
"day": "日",
"hour": "時"
},
"operators": {
"eq": "等しい",
"ne": "等しくない",
"gt": "より大きい",
"lt": "未満",
"ge": "以上",
"le": "以下",
"like": "パターン",
"range": "範囲"
},
"events": {
"create": "作成",
"update": "更新",
"discard": "破棄",
"restore": "復元"
},
"attributes": {
"sourceLanguage": "翻訳元の言語",
"source_language_code": "翻訳元の言語",
"source_version_no": "翻訳元の版番号",
"event_type": "異動区分",
"body": "本文",
"linked_tags": "対応タグ",
"description": "説明",
"members": "構成要素",
"placements": "配置",
"parent_id": "親素材",
"tag_id": "タグ",
"tag_name": "タグ名称",
"tag_category": "タグカテゴリ",
"discarded_at": "破棄日時",
"updated_at": "履歴行更新日時",
"updated_by_user_id": "履歴行更新者",
"export_paths_json": "出力先",
"file_blob_id": "ファイル識別子",
"file_filename": "ファイル名",
"file_content_type": "ファイル形式",
"file_byte_size": "ファイル容量",
"file_checksum": "チェックサム",
"file_sha256": "SHA-256",
"source_kind": "取得元の種類",
"source_uri": "取得元 URI",
"source_path": "取得元のパス",
"source_file_id": "取得元ファイル識別子",
"normalized_source_key": "取得元識別キー",
"parent_post_ids": "親投稿",
"parent_tag_ids": "上位タグ",
"video_ms": "動画時間",
"original_created_from": "オリジナルの投稿日時(開始境界)",
"original_created_before": "オリジナルの投稿日時(終了境界)",
"deprecated_at": "廃止日時",
"section_name": "節の名称",
"children": "子項目",
"profile": "出力設定",
"path": "出力パス"
} }
} }
+52 -38
ファイルの表示
@@ -16,12 +16,15 @@ import { Button } from '@/components/ui/button'
import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts, import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts' isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay' import { countsExportLabels } from '@/lib/countsDisplay'
import { selectedCountsSummaryFields } from '@/lib/countsState' import { countsAxisFields, countsResources, countsFieldLabel, readCountsJSON }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys' import { countsKeys } from '@/lib/queryKeys'
import { cn, inputClass } 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'
import type { CountsAxis } from '@/lib/countsDefinitions'
import type { CountsResource } from '@/lib/counts' import type { CountsResource } from '@/lib/counts'
@@ -40,19 +43,21 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const mode = params.get ('mode') ?? 'summary' const mode = params.get ('mode') ?? 'summary'
const state = isCountsState (params) const state = isCountsState (params)
const cacheParams = new URLSearchParams (request) const cacheParams = new URLSearchParams (request)
cacheParams.set ('basis', state ? 'state' : 'history') cacheParams.set ('definition', state ? 'latest' : 'history')
const [format, setFormat] = useState<'json' | 'csv'> ('json') const [format, setFormat] = useState<'json' | 'csv'> ('json')
const [exporting, setExporting] = useState (false) const [exporting, setExporting] = useState (false)
const [exportError, setExportError] = useState (false) const [exportError, setExportError] = useState (false)
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({ const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
enabled: params.has ('timezone'),
queryKey: countsKeys.index (resource, cacheParams.toString (), locale), queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
queryFn: () => fetchCounts (resource, params, locale), retry: false, queryFn: () => fetchCounts (resource, params, locale), retry: false,
placeholderData: (previous, query) => { placeholderData: (previous, query) => {
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? '')) const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
&& previousParams.get ('mode') === mode && previousParams.get ('mode') === mode
&& previousParams.get ('axis') === params.get ('axis') && previousParams.get ('rows') === params.get ('rows')
&& previousParams.get ('basis') === cacheParams.get ('basis')) ? previous : undefined && previousParams.get ('cols') === params.get ('cols')
&& previousParams.get ('definition') === cacheParams.get ('definition')) ? previous : undefined
} }) } })
useEffect (() => { useEffect (() => {
@@ -61,12 +66,21 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}, [canonical, source, setParams]) }, [canonical, source, setParams])
useEffect (() => { useEffect (() => {
if (data?.at == null || isPlaceholderData) return const timezone = Intl.DateTimeFormat ().resolvedOptions ().timeZone
const next = new URLSearchParams (canonical) if (params.get ('timezone') !== timezone)
if (!next.get ('at')) next.set ('at', data.at) {
const next = new URLSearchParams (canonical)
next.set ('timezone', timezone)
setParams (next, { replace: true })
}
else if (data?.executionAt != null && !isPlaceholderData && !params.has ('execution_at'))
{
const next = new URLSearchParams (canonical)
next.set ('execution_at', data.executionAt)
setParams (next, { replace: true })
}
}, [canonical, data, isPlaceholderData, params, setParams])
if (next.toString () !== canonical) setParams (next, { replace: true })
}, [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)
@@ -80,18 +94,19 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
setExportError (false) setExportError (false)
try try
{ {
const fields = mode === 'summary' ? selectedCountsSummaryFields (resource, params) const fields = mode === 'summary' ? [
...[...readCountsJSON<CountsAxis[]> (params, 'rows', []),
...readCountsJSON<CountsAxis[]> (params, 'cols', [])].map (axis => ({
key: axis.field, label: countsFieldLabel (axis.field) })),
{ key: 'count', label: 'count' }]
: selectedCountColumns (resource, params).map (column => : selectedCountColumns (resource, params).map (column =>
fieldsForCounts (resource).find (field => field.key === column)!) fieldsForCounts (resource).find (field => field.key === column)!)
const file = await exportCounts (resource, params, locale, format, await exportCounts (resource, params, locale, format,
fields.map (field => field.key), fields.map (field => message (field.label).text), fields.map (field => field.key), fields.map (field => message (field.label).text),
countsExportLabels (resource, locale)) { ...countsExportLabels (resource, locale),
const url = URL.createObjectURL (file) ...Object.fromEntries (countsAxisFields (resource, !state).map (field => [
const link = document.createElement ('a') field.key, message (field.label).text])), count: message ('count').text })
link.href = url
link.download = `counts-${resource}.${format}`
link.click ()
setTimeout (() => URL.revokeObjectURL (url), 1000)
} }
catch catch
{ {
@@ -117,17 +132,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 => { {countsResources.map (target => <li key={target}>
const next = new URLSearchParams (params) <span>{label (`resources.${target}`)}</span>
for (const key of ['resource_id', 'tag', 'columns', 'summary_columns', <ul className="ml-3">
'sort', 'page', 'children', 'child_sort']) next.delete (key) {['latest', 'history'].map (definition => {
return <li key={target}><PrefetchLink const next = new URLSearchParams ({ definition, mode })
to={`/counts/${target}?${normaliseCountsParams (target, next)}`} for (const key of ['timezone', 'at', 'execution_at'])
aria-current={target === resource ? 'page' : undefined} if (params.get (key)) next.set (key, params.get (key)!)
className={cn ('block py-1', target === resource && 'font-bold underline')}> const active = target === resource
{label (target === 'posts' ? 'history:post' : 'navigation:tags')} && params.get ('definition') === definition
</PrefetchLink></li> return <li key={definition}><PrefetchLink
})} to={`/counts/${target}?${next}`}
aria-current={active ? 'page' : undefined}
className={cn ('block py-1', active && 'font-bold underline')}>
{label (definition)}</PrefetchLink></li>
})}
</ul>
</li>)}
</ul> </ul>
</nav> </nav>
</SidebarComponent> </SidebarComponent>
@@ -153,7 +174,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
</select> </select>
<Button type="button" variant="outline" size="sm" <Button type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData disabled={data == null || isError || isPlaceholderData
|| exporting || !params.get ('at')} || exporting || !params.get ('execution_at')}
onClick={() => void download ()}>{label ('export')}</Button> onClick={() => void download ()}>{label ('export')}</Button>
</>}/> </>}/>
{exportError && <p role="alert">{label ('common:fetchError')}</p>} {exportError && <p role="alert">{label ('common:fetchError')}</p>}
@@ -161,14 +182,7 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
{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 && <>
{data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs"> <p className="my-1 text-xs">{label ('recordTotal', { count: data.count })}</p>
<span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
{data.totals && (['posts', 'tags', 'assignments'] as const).map (key => (
<span key={key}>{label (`state.totals.${key}`)}:
{data.totals![key] ?? '—'}
</span>))}
</div>}
{!state && <p className="my-1 text-xs">{label ('total', { count: data.count })}</p>}
{mode === 'summary' ? <CountsSummaryResults resource={resource} {mode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={params} data={data} params={params}
pending={isPlaceholderData} update={update}/> : ( pending={isPlaceholderData} update={update}/> : (