このコミットが含まれているのは:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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 } }]
|
||||||
|
|||||||
@@ -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}/>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>}
|
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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':
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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": "出力パス"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/> : (
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする