このコミットが含まれているのは:
@@ -11,7 +11,10 @@
|
|||||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||||
サーバーが確定した current_user の role のみを認可に使用する。
|
サーバーが確定した current_user の role のみを認可に使用する。
|
||||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
|
||||||
|
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
|
||||||
|
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
|
||||||
|
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
|
||||||
|
|
||||||
## Mandatory naming contract
|
## Mandatory naming contract
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,10 @@
|
|||||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||||
サーバーが確定した current_user の role のみを認可に使用する。
|
サーバーが確定した current_user の role のみを認可に使用する。
|
||||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
|
||||||
|
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
|
||||||
|
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
|
||||||
|
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
|
||||||
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
|
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
|
||||||
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
|
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
|
|||||||
# This number deliberately ignores time, identity and content filters to prevent count probes.
|
# This number deliberately ignores time, identity and content filters to prevent count probes.
|
||||||
def self.population_count(model) = model.unscoped.count
|
def self.population_count(model) = model.unscoped.count
|
||||||
|
|
||||||
# Only the resource-wide denied count is public, never a condition-dependent denied count.
|
# Resource-wide totals provide a fast path; Counts computes filtered totals separately.
|
||||||
def self.population_counts(model)
|
def self.population_counts(model)
|
||||||
total, excluded = model.unscoped.optimizer_hints('MAX_EXECUTION_TIME(10000)').pick(
|
total, excluded = model.unscoped.optimizer_hints('MAX_EXECUTION_TIME(10000)').pick(
|
||||||
Arel.sql('COUNT(*)'),
|
Arel.sql('COUNT(*)'),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class CountsVersionRepr
|
|||||||
@rows.map do |row|
|
@rows.map do |row|
|
||||||
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_id)
|
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_id)
|
||||||
common = { id: row.id, resource_id:,
|
common = { id: row.id, resource_id:,
|
||||||
version_no: row.version_no, is_latest: row.attributes['counts_latest'].to_i == 1,
|
version_no: row.version_no, latest: row.attributes['counts_latest'].to_i == 1,
|
||||||
created_at: row.created_at.iso8601,
|
created_at: row.created_at.iso8601,
|
||||||
created_by_user: row.created_by_user_id &&
|
created_by_user: row.created_by_user_id &&
|
||||||
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
|
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class CountsCondition
|
|||||||
path_labels = labels + [field[:label]]
|
path_labels = labels + [field[:label]]
|
||||||
type = if prefix
|
type = if prefix
|
||||||
CHILD_TYPES.fetch(field[:key], :string)
|
CHILD_TYPES.fetch(field[:key], :string)
|
||||||
elsif key == 'is_latest'
|
elsif key == 'latest'
|
||||||
:boolean
|
:boolean
|
||||||
elsif key == 'parents'
|
elsif key == 'parents'
|
||||||
:integer
|
:integer
|
||||||
@@ -27,7 +27,7 @@ class CountsCondition
|
|||||||
type = :string if field[:children] || type == :json
|
type = :string if field[:children] || type == :json
|
||||||
type = :integer if ['parents', 'members'].include?(key)
|
type = :integer if ['parents', 'members'].include?(key)
|
||||||
physical = result.find { _1[:key] == key }
|
physical = result.find { _1[:key] == key }
|
||||||
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'is_latest')
|
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'latest')
|
||||||
nullable = false if field[:children]
|
nullable = false if field[:children]
|
||||||
entry = { key:, type:, labels: path_labels, nullable:,
|
entry = { key:, type:, labels: path_labels, nullable:,
|
||||||
multiple: prefix.present? || field[:children].present?,
|
multiple: prefix.present? || field[:children].present?,
|
||||||
@@ -204,7 +204,7 @@ class CountsCondition
|
|||||||
bound = @connection.quote(point)
|
bound = @connection.quote(point)
|
||||||
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
|
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
|
||||||
" AND parent_version.created_at <= #{ bound } AND " +
|
" AND parent_version.created_at <= #{ bound } AND " +
|
||||||
HistoryAccess.sql(TagVersion, 'parent_version') +
|
@population.access_sql(TagVersion, 'parent_version') +
|
||||||
' AND NOT EXISTS (SELECT 1 FROM tag_versions newer_parent ' +
|
' AND NOT EXISTS (SELECT 1 FROM tag_versions newer_parent ' +
|
||||||
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
|
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
|
||||||
"AND newer_parent.created_at <= #{ bound } " +
|
"AND newer_parent.created_at <= #{ bound } " +
|
||||||
@@ -218,7 +218,7 @@ class CountsCondition
|
|||||||
scalar('tag_snapshot.item', 'tag_id', :integer) +
|
scalar('tag_snapshot.item', 'tag_id', :integer) +
|
||||||
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
|
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
|
||||||
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
|
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
|
||||||
HistoryAccess.sql(TagVersion, 'referenced_tag')
|
@population.access_sql(TagVersion, 'referenced_tag')
|
||||||
source
|
source
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ class CountsCondition
|
|||||||
|
|
||||||
def source_for(key)
|
def source_for(key)
|
||||||
root, *parts = key.split('.')
|
root, *parts = key.split('.')
|
||||||
return [nil, "(#{ @population.latest_sql })"] if root == 'is_latest'
|
return [nil, "(#{ @population.latest_sql })"] if root == 'latest'
|
||||||
if root == 'user' && parts.any?
|
if root == 'user' && parts.any?
|
||||||
expression = parts.first == 'id' ? 'v.created_by_user_id' :
|
expression = parts.first == 'id' ? 'v.created_by_user_id' :
|
||||||
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
|
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Only confirmed combinations belong here; all others retain the existing behaviour.
|
# Only confirmed combinations belong here; all others retain the existing behaviour.
|
||||||
class CountsDefaults
|
class CountsDefaults
|
||||||
POST_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'title', 'url',
|
POST_COLUMNS = ['resource_id', 'version_no', 'latest', 'title', 'url',
|
||||||
'thumbnail_base', 'tags', 'parents', 'original_created_from',
|
'thumbnail_base', 'tags', 'parents', 'original_created_from',
|
||||||
'original_created_before', 'event_type', 'user', 'created_at'].freeze
|
'original_created_before', 'event_type', 'user', 'created_at'].freeze
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class CountsDefaults
|
|||||||
'children' => CountsExportProjection.default_children(resource).join(' ') }
|
'children' => CountsExportProjection.default_children(resource).join(' ') }
|
||||||
end
|
end
|
||||||
|
|
||||||
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
|
columns = ['resource_id', 'version_no', 'latest', 'name', 'category',
|
||||||
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
|
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
|
||||||
{ 'columns' => columns.join(' '), 'order' => columns.join(' '),
|
{ 'columns' => columns.join(' '), 'order' => columns.join(' '),
|
||||||
'children' => 'aliases.name parents.name', 'nested_json' => '0' }
|
'children' => 'aliases.name parents.name', 'nested_json' => '0' }
|
||||||
|
|||||||
@@ -3,7 +3,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 = { 'event_type' => 'event_type', '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',
|
'latest' => 'latest', 'thumbnail' => 'thumbnail_url',
|
||||||
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
|
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
|
||||||
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
|
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
|
||||||
'original_created_from' => 'original_created_from',
|
'original_created_from' => 'original_created_from',
|
||||||
@@ -42,6 +42,7 @@ class CountsExportProjection
|
|||||||
attr_reader :columns, :headers
|
attr_reader :columns, :headers
|
||||||
|
|
||||||
def initialize(params, resource:, summary:, tags:)
|
def initialize(params, resource:, summary:, tags:)
|
||||||
|
params = CountsResourceDefinition.normalise_params(params)
|
||||||
if params.key?('definition')
|
if params.key?('definition')
|
||||||
params = CountsDefaults.for(params.merge('resource' => resource)).merge(params)
|
params = CountsDefaults.for(params.merge('resource' => resource)).merge(params)
|
||||||
end
|
end
|
||||||
@@ -144,7 +145,7 @@ class CountsExportProjection
|
|||||||
def csv_display(value, key)
|
def csv_display(value, key)
|
||||||
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
||||||
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
|
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
|
||||||
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
|
return value ? @labels.fetch('latest', 'true') : '' if key == 'latest'
|
||||||
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
|
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
|
||||||
return @labels.fetch("category:#{ value }", value) if key == 'category'
|
return @labels.fetch("category:#{ value }", value) if key == 'category'
|
||||||
if ['language', 'script'].include?(key) && value.is_a?(Hash)
|
if ['language', 'script'].include?(key) && value.is_a?(Hash)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class CountsExporter
|
|||||||
attr_reader :format, :resource
|
attr_reader :format, :resource
|
||||||
|
|
||||||
def initialize(params, language_code:)
|
def initialize(params, language_code:)
|
||||||
|
params = CountsResourceDefinition.normalise_params(params)
|
||||||
if params.key?('definition')
|
if params.key?('definition')
|
||||||
@table_export = CountsTableExport.new(params)
|
@table_export = CountsTableExport.new(params)
|
||||||
@format = @table_export.format
|
@format = @table_export.format
|
||||||
@@ -66,7 +67,7 @@ class CountsExporter
|
|||||||
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
||||||
columns: @projection.metadata,
|
columns: @projection.metadata,
|
||||||
children: @params['children']&.split(' '),
|
children: @params['children']&.split(' '),
|
||||||
field_order: @QueryState.list(params.fetch('order', '')), timezone: 'UTC',
|
field_order: QueryState.list(@params.fetch('order', '')), timezone: 'UTC',
|
||||||
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
||||||
totals: page[:totals], coverage: page[:coverage] }
|
totals: page[:totals], coverage: page[:coverage] }
|
||||||
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
class CountsPopulation
|
class CountsPopulation
|
||||||
attr_reader :definition, :params, :at, :history, :scope, :timezone
|
attr_reader :definition, :params, :at, :history, :scope, :timezone
|
||||||
|
|
||||||
def initialize(params)
|
def initialize(params, authorisation_flg: true)
|
||||||
|
@authorisation_flg = authorisation_flg
|
||||||
@params = params
|
@params = params
|
||||||
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
|
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
|
||||||
@history = params.fetch('definition', 'latest') == 'history'
|
@history = params.fetch('definition', 'latest') == 'history'
|
||||||
@@ -14,7 +15,7 @@ class CountsPopulation
|
|||||||
|
|
||||||
@scope = definition.model.all
|
@scope = definition.model.all
|
||||||
make_population
|
make_population
|
||||||
@scope = HistoryAccess.visible(@scope, 'v')
|
@scope = HistoryAccess.visible(@scope, 'v') if @authorisation_flg
|
||||||
apply_filters
|
apply_filters
|
||||||
rescue TZInfo::InvalidTimezoneIdentifier
|
rescue TZInfo::InvalidTimezoneIdentifier
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
@@ -56,6 +57,11 @@ class CountsPopulation
|
|||||||
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
|
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Unrestricted predicates are used only for numerical totals, never record output.
|
||||||
|
def access_sql(model, row)
|
||||||
|
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def make_population
|
def make_population
|
||||||
@@ -64,7 +70,7 @@ class CountsPopulation
|
|||||||
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
||||||
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
||||||
'AND previous.version_no = v.version_no - 1 AND ' +
|
'AND previous.version_no = v.version_no - 1 AND ' +
|
||||||
HistoryAccess.sql(definition.model, 'previous'))
|
access_sql(definition.model, 'previous'))
|
||||||
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
|
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
|
||||||
unless history
|
unless history
|
||||||
point = timestamp(params['at']) || at
|
point = timestamp(params['at']) || at
|
||||||
|
|||||||
@@ -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', 'event_type', 'user', 'created_at'].freeze
|
COMMON_COLUMNS = ['resource_id', 'version_no', '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',
|
||||||
@@ -15,6 +15,7 @@ class CountsQuery
|
|||||||
attr_reader :page, :limit, :mode, :axis
|
attr_reader :page, :limit, :mode, :axis
|
||||||
|
|
||||||
def initialize(scope, resource, params, latest_expression: nil)
|
def initialize(scope, resource, params, latest_expression: nil)
|
||||||
|
params = CountsResourceDefinition.normalise_params(params)
|
||||||
@table = scope.klass.table_name
|
@table = scope.klass.table_name
|
||||||
@resource = resource
|
@resource = resource
|
||||||
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
||||||
@@ -117,7 +118,7 @@ class CountsQuery
|
|||||||
def detail_expressions
|
def detail_expressions
|
||||||
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
|
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
|
||||||
'version_no' => ["#{ @table }.version_no"],
|
'version_no' => ["#{ @table }.version_no"],
|
||||||
'is_latest' => ["(#{ latest_expression })"],
|
'latest' => ["(#{ latest_expression })"],
|
||||||
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
|
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
|
||||||
'created_at' => ["#{ @table }.created_at"] }
|
'created_at' => ["#{ @table }.created_at"] }
|
||||||
if @resource == 'posts'
|
if @resource == 'posts'
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Server-owned version identities, attributes and field types. No client SQL is accepted.
|
# Server-owned version identities, attributes and field types. No client SQL is accepted.
|
||||||
class CountsResourceDefinition
|
class CountsResourceDefinition
|
||||||
|
# Compatibility belongs at the request boundary, not in field definitions.
|
||||||
|
def self.normalise_params(params)
|
||||||
|
params.each_with_object({}) do |(key, value), result|
|
||||||
|
result[key] = value
|
||||||
|
next unless value.is_a?(String)
|
||||||
|
|
||||||
|
if ['columns', 'order', 'sort', 'rows', 'cols', 'axis'].include?(key)
|
||||||
|
result[key] = value.split(/ /, -1).map { |token|
|
||||||
|
token.sub(/\Ais_latest(?=:|\z)/, 'latest')
|
||||||
|
}.join(' ')
|
||||||
|
elsif (key.match?(/\A(?:conditions|drill)\.[0-9]+\.field\z/) &&
|
||||||
|
value == 'is_latest')
|
||||||
|
result[key] = 'latest'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
|
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
|
||||||
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
|
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
|
||||||
DEFINITIONS = {
|
DEFINITIONS = {
|
||||||
@@ -35,7 +52,7 @@ class CountsResourceDefinition
|
|||||||
interval: ['range'] }.freeze
|
interval: ['range'] }.freeze
|
||||||
LABELS = {
|
LABELS = {
|
||||||
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
|
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
|
||||||
'is_latest' => 'counts:columns.latest', 'event_type' => 'counts:eventType',
|
'latest' => 'counts:columns.latest', 'event_type' => 'counts:eventType',
|
||||||
'user' => 'history:actor', 'created_at' => 'counts:columns.changedAt',
|
'user' => 'history:actor', 'created_at' => 'counts:columns.changedAt',
|
||||||
'thumbnail' => 'counts:columns.thumbnailDisplay',
|
'thumbnail' => 'counts:columns.thumbnailDisplay',
|
||||||
'thumbnail_base' => 'counts:columns.thumbnailURL',
|
'thumbnail_base' => 'counts:columns.thumbnailURL',
|
||||||
@@ -101,7 +118,7 @@ class CountsResourceDefinition
|
|||||||
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
||||||
else fields.keys - COMMON.keys
|
else fields.keys - COMMON.keys
|
||||||
end
|
end
|
||||||
keys = ['resource_id', 'version_no', 'is_latest'] + (content - ['thumbnail']) +
|
keys = ['resource_id', 'version_no', 'latest'] + (content - ['thumbnail']) +
|
||||||
['event_type', 'user', 'created_at']
|
['event_type', 'user', 'created_at']
|
||||||
keys.unshift('thumbnail') if content.include?('thumbnail')
|
keys.unshift('thumbnail') if content.include?('thumbnail')
|
||||||
keys.map do |key|
|
keys.map do |key|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class CountsTableExport
|
|||||||
attr_reader :format, :resource
|
attr_reader :format, :resource
|
||||||
|
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
|
params = CountsResourceDefinition.normalise_params(params)
|
||||||
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
|
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
|
||||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class CountsTableExport
|
|||||||
if ['posts', 'tags'].include?(resource)
|
if ['posts', 'tags'].include?(resource)
|
||||||
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
|
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
|
||||||
else
|
else
|
||||||
allowed = @query.population.definition.fields.keys + ['is_latest']
|
allowed = @query.population.definition.fields.keys + ['latest']
|
||||||
raise ArgumentError if (@columns - allowed).any?
|
raise ArgumentError if (@columns - allowed).any?
|
||||||
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
|
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
|
||||||
end
|
end
|
||||||
@@ -305,7 +306,7 @@ class CountsTableExport
|
|||||||
|
|
||||||
def csv_logical(key, value)
|
def csv_logical(key, value)
|
||||||
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
|
||||||
return value ? @labels.fetch('latest', '') : '' if key == 'is_latest'
|
return value ? @labels.fetch('latest', '') : '' if key == 'latest'
|
||||||
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
||||||
return @query.population.timezone.utc_to_local(value.utc).iso8601
|
return @query.population.timezone.utc_to_local(value.utc).iso8601
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class CountsTableQuery
|
|||||||
attr_reader :population, :axes, :page, :limit, :mode
|
attr_reader :population, :axes, :page, :limit, :mode
|
||||||
|
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
|
params = CountsResourceDefinition.normalise_params(params)
|
||||||
QueryState.validate!(params, KEYS + ['measures'])
|
QueryState.validate!(params, KEYS + ['measures'])
|
||||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ class CountsTableQuery
|
|||||||
|
|
||||||
@sort = QueryState.list(params.fetch('sort', '')).map { _1.split(':', -1) }
|
@sort = QueryState.list(params.fetch('sort', '')).map { _1.split(':', -1) }
|
||||||
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
||||||
@definition.fields.keys + ['is_latest'] +
|
@definition.fields.keys + ['latest'] +
|
||||||
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
||||||
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
||||||
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
||||||
@@ -43,9 +44,10 @@ class CountsTableQuery
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
result = metadata.merge(mode == 'summary' ? summary : details)
|
result = metadata.merge(mode == 'summary' ? summary : details)
|
||||||
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
||||||
|
totals = output_totals(visible)
|
||||||
result.merge(visible_output_count: visible,
|
result.merge(visible_output_count: visible,
|
||||||
output_count: visible,
|
**totals,
|
||||||
output_count_scope: 'authorised_results',
|
output_count_scope: 'before_authorisation',
|
||||||
output_count_unit: 'table_body_rows',
|
output_count_unit: 'table_body_rows',
|
||||||
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
||||||
result[:group_count] > limit)
|
result[:group_count] > limit)
|
||||||
@@ -54,12 +56,8 @@ class CountsTableQuery
|
|||||||
|
|
||||||
def metadata
|
def metadata
|
||||||
@population_counts ||= HistoryAccess.population_counts(@definition.model)
|
@population_counts ||= HistoryAccess.population_counts(@definition.model)
|
||||||
# Global exclusions are not exclusions for this filtered query. Do not expose
|
|
||||||
# private predicate matches through a condition-dependent count.
|
|
||||||
exclusions_known = @population_counts[:excluded_count].zero?
|
|
||||||
@population_counts.merge(
|
@population_counts.merge(
|
||||||
excluded_count: exclusions_known ? 0 : nil,
|
excluded_count: nil, excluded_count_scope: 'current_query',
|
||||||
excluded_count_scope: exclusions_known ? 'current_query' : 'unavailable_private_history',
|
|
||||||
population_count_scope: 'all_recorded_versions',
|
population_count_scope: 'all_recorded_versions',
|
||||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||||
definition: population.history ? 'history' : 'latest',
|
definition: population.history ? 'history' : 'latest',
|
||||||
@@ -186,7 +184,7 @@ class CountsTableQuery
|
|||||||
[key, row.public_send(@definition.column(key))]
|
[key, row.public_send(@definition.column(key))]
|
||||||
end
|
end
|
||||||
attributes.merge('id' => row.id, 'resource_id' => row[@definition.keys.first],
|
attributes.merge('id' => row.id, 'resource_id' => row[@definition.keys.first],
|
||||||
'is_latest' => row.attributes['counts_latest'].to_i == 1,
|
'latest' => row.attributes['counts_latest'].to_i == 1,
|
||||||
'created_by_user' => row.created_by_user_id &&
|
'created_by_user' => row.created_by_user_id &&
|
||||||
{ id: row.created_by_user_id, name: users[row.created_by_user_id] })
|
{ id: row.created_by_user_id, name: users[row.created_by_user_id] })
|
||||||
end
|
end
|
||||||
@@ -217,12 +215,55 @@ class CountsTableQuery
|
|||||||
"(SELECT COUNT(*) FROM (SELECT #{ keys } FROM cells GROUP BY #{ keys }) subtotal_rows)"
|
"(SELECT COUNT(*) FROM (SELECT #{ keys } FROM cells GROUP BY #{ keys }) subtotal_rows)"
|
||||||
end
|
end
|
||||||
expressions = [total.to_s, '1'] + subtotals
|
expressions = [total.to_s, '1'] + subtotals
|
||||||
@output_row_count ||= @connection.select_value(<<~SQL).to_i
|
@connection.select_value(<<~SQL).to_i
|
||||||
#{ prefix }
|
#{ prefix }
|
||||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ #{ expressions.join(' + ') } AS output_rows
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ #{ expressions.join(' + ') } AS output_rows
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def output_totals(visible_rows)
|
||||||
|
return @output_totals if @output_totals
|
||||||
|
|
||||||
|
if HistoryAccess.admin? || @population_counts[:excluded_count].zero?
|
||||||
|
return @output_totals = { output_count: visible_rows, excluded_count: 0 }
|
||||||
|
end
|
||||||
|
# Only numerical aggregates leave this population. Labels, snapshots and
|
||||||
|
# detail pagination continue to use the authorised population exclusively.
|
||||||
|
source = CountsPopulation.new(
|
||||||
|
@params.merge('execution_at' => population.at.iso8601(6)), authorisation_flg: false)
|
||||||
|
records = source.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count
|
||||||
|
denied = source.scope.except(:select)
|
||||||
|
.where(Arel.sql("NOT (#{ HistoryAccess.sql(@definition.model, 'v') })"))
|
||||||
|
.optimizer_hints('MAX_EXECUTION_TIME(10000)').count
|
||||||
|
rows = records
|
||||||
|
if mode == 'summary'
|
||||||
|
unrestricted_axes = CountsAxes.new(source)
|
||||||
|
relation = source.scope
|
||||||
|
joins = unrestricted_axes.joins
|
||||||
|
relation = relation.joins(joins) unless joins.empty?
|
||||||
|
expressions = unrestricted_axes.expressions
|
||||||
|
keys = axes.rows.each_index.map { "a#{ _1 }" }
|
||||||
|
if keys.empty?
|
||||||
|
rows = 1
|
||||||
|
else
|
||||||
|
selected = expressions.each_with_index.map { |expression, index|
|
||||||
|
"#{ expression } AS a#{ index }"
|
||||||
|
}
|
||||||
|
sql = relation.except(:select).select(Arel.sql(selected.join(', ')))
|
||||||
|
.distinct.to_sql
|
||||||
|
prefix = "WITH cells AS (#{ sql })"
|
||||||
|
groups = @connection.select_value(<<~SQL).to_i
|
||||||
|
#{ prefix }
|
||||||
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (
|
||||||
|
SELECT #{ keys.join(', ') } FROM cells GROUP BY #{ keys.join(', ') }
|
||||||
|
) output_groups
|
||||||
|
SQL
|
||||||
|
rows = output_row_count(prefix, keys, CountsPivotLayout.depths(keys, @sort), groups)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@output_totals = { output_count: rows, excluded_count: denied }
|
||||||
|
end
|
||||||
|
|
||||||
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
|
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
|
||||||
|
|
||||||
def limited_columns(prefix)
|
def limited_columns(prefix)
|
||||||
@@ -284,7 +325,7 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
|
|
||||||
def detail_expression(key)
|
def detail_expression(key)
|
||||||
return "(#{ population.latest_sql })" if key == 'is_latest'
|
return "(#{ population.latest_sql })" if key == 'latest'
|
||||||
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
|
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
|
||||||
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' }
|
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' }
|
||||||
field = aliases.fetch(key, key)
|
field = aliases.fetch(key, key)
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,10 @@
|
|||||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||||
サーバーが確定した current_user の role のみを認可に使用する。
|
サーバーが確定した current_user の role のみを認可に使用する。
|
||||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
|
||||||
|
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
|
||||||
|
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
|
||||||
|
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
|
||||||
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
||||||
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
|
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const CountsControls = (
|
|||||||
const mode = draft.get ('mode') ?? 'summary'
|
const mode = draft.get ('mode') ?? 'summary'
|
||||||
const columns = selectedCountColumns (resource, draft, definition)
|
const columns = selectedCountColumns (resource, draft, definition)
|
||||||
const details = mode === 'details'
|
const details = mode === 'details'
|
||||||
const hasNested = details && fieldsForCounts (resource, definition).some (field =>
|
const nestedFlg = details && fieldsForCounts (resource, definition).some (field =>
|
||||||
columns.includes (field.key) && field.children != null
|
columns.includes (field.key) && field.children != null
|
||||||
&& !countsNestedIsScalar (field.children, field.key, draft)
|
&& !countsNestedIsScalar (field.children, field.key, draft)
|
||||||
&& field.children.some (child => selectedCountChildren (draft)
|
&& field.children.some (child => selectedCountChildren (draft)
|
||||||
@@ -163,7 +163,7 @@ const CountsControls = (
|
|||||||
onChange={event => displayThumbnail (event.target.checked)}/>
|
onChange={event => displayThumbnail (event.target.checked)}/>
|
||||||
{label ('columns.thumbnailDisplay')}
|
{label ('columns.thumbnailDisplay')}
|
||||||
</label>}
|
</label>}
|
||||||
{hasNested && <label className="inline-flex items-center gap-1 text-sm">
|
{nestedFlg && <label className="inline-flex items-center gap-1 text-sm">
|
||||||
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
|
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
update ('nested_json', event.target.checked ? '1' : '0')
|
update ('nested_json', event.target.checked ? '1' : '0')
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const CountsResults = (
|
|||||||
case 'thumbnail_base':
|
case 'thumbnail_base':
|
||||||
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
|
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
|
||||||
{value}</a>
|
{value}</a>
|
||||||
case 'is_latest':
|
case 'latest':
|
||||||
return <LocalisedText language={message ('latestValue').language}>
|
return <LocalisedText language={message ('latestValue').language}>
|
||||||
{value}</LocalisedText>
|
{value}</LocalisedText>
|
||||||
case 'user':
|
case 'user':
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const CountsSelectableTable = (
|
|||||||
) => {
|
) => {
|
||||||
const id = useId ()
|
const id = useId ()
|
||||||
const root = useRef<HTMLTableElement> (null)
|
const root = useRef<HTMLTableElement> (null)
|
||||||
|
const textCell = useRef<HTMLTableCellElement | null> (null)
|
||||||
const dragging = useRef (false)
|
const dragging = useRef (false)
|
||||||
const touchArmed = useRef (false)
|
const touchArmed = useRef (false)
|
||||||
const touchPress = useRef<{ x: number; y: number
|
const touchPress = useRef<{ x: number; y: number
|
||||||
@@ -64,6 +65,15 @@ const CountsSelectableTable = (
|
|||||||
const selection = stored?.identity === identity ? stored : null
|
const selection = stored?.identity === identity ? stored : null
|
||||||
const layout = useCountsTableLayout ()
|
const layout = useCountsTableLayout ()
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
|
const clearTextSelection = useCallback (() => {
|
||||||
|
const cell = textCell.current
|
||||||
|
if (cell == null) return
|
||||||
|
delete cell.dataset.countsText
|
||||||
|
const text = window.getSelection ()
|
||||||
|
if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges ()
|
||||||
|
textCell.current = null
|
||||||
|
}, [])
|
||||||
|
useEffect (() => () => clearTextSelection (), [identity, clearTextSelection])
|
||||||
const bounds = useMemo (() => selection == null ? null : {
|
const bounds = useMemo (() => selection == null ? null : {
|
||||||
top: Math.min (selection.anchor.row, selection.end.row),
|
top: Math.min (selection.anchor.row, selection.end.row),
|
||||||
bottom: Math.max (selection.anchor.row, selection.end.row),
|
bottom: Math.max (selection.anchor.row, selection.end.row),
|
||||||
@@ -198,6 +208,14 @@ const CountsSelectableTable = (
|
|||||||
closeMenu ()
|
closeMenu ()
|
||||||
}
|
}
|
||||||
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
||||||
|
const text = window.getSelection ()
|
||||||
|
if (textCell.current != null && text != null && !text.isCollapsed
|
||||||
|
&& textCell.current.contains (text.anchorNode)
|
||||||
|
&& textCell.current.contains (text.focusNode))
|
||||||
|
{
|
||||||
|
event.stopPropagation ()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (document.activeElement !== root.current) return
|
if (document.activeElement !== root.current) return
|
||||||
const payload = clipboard ()
|
const payload = clipboard ()
|
||||||
if (payload == null) return
|
if (payload == null) return
|
||||||
@@ -232,6 +250,26 @@ const CountsSelectableTable = (
|
|||||||
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
||||||
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
|
onDoubleClick={event => {
|
||||||
|
const element = event.target as Element
|
||||||
|
if (element.closest ('table') !== event.currentTarget
|
||||||
|
|| element.closest (interactive) != null) return
|
||||||
|
const cell = element.closest ('td')
|
||||||
|
if (cell == null) return
|
||||||
|
event.preventDefault ()
|
||||||
|
event.stopPropagation ()
|
||||||
|
finish ()
|
||||||
|
clearTextSelection ()
|
||||||
|
setStored (null)
|
||||||
|
setMenu (null)
|
||||||
|
textCell.current = cell
|
||||||
|
cell.dataset.countsText = ''
|
||||||
|
const range = document.createRange ()
|
||||||
|
range.selectNodeContents (cell)
|
||||||
|
const text = window.getSelection ()
|
||||||
|
text?.removeAllRanges ()
|
||||||
|
text?.addRange (range)
|
||||||
|
}}
|
||||||
onClickCapture={event => {
|
onClickCapture={event => {
|
||||||
if ((event.target as Element).closest ('table') !== event.currentTarget
|
if ((event.target as Element).closest ('table') !== event.currentTarget
|
||||||
|| !suppressClick.current) return
|
|| !suppressClick.current) return
|
||||||
@@ -243,6 +281,12 @@ const CountsSelectableTable = (
|
|||||||
const element = event.target as Element
|
const element = event.target as Element
|
||||||
if (element.closest ('table') !== event.currentTarget) return
|
if (element.closest ('table') !== event.currentTarget) return
|
||||||
suppressClick.current = false
|
suppressClick.current = false
|
||||||
|
if (textCell.current != null && textCell.current.contains (element))
|
||||||
|
{
|
||||||
|
event.stopPropagation ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTextSelection ()
|
||||||
if (element.closest (interactive) != null || event.button !== 0) return
|
if (element.closest (interactive) != null || event.button !== 0) return
|
||||||
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
|
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
|
||||||
{
|
{
|
||||||
@@ -300,6 +344,11 @@ const CountsSelectableTable = (
|
|||||||
onContextMenu={event => {
|
onContextMenu={event => {
|
||||||
const element = event.target as Element
|
const element = event.target as Element
|
||||||
if (element.closest ('table') !== event.currentTarget) return
|
if (element.closest ('table') !== event.currentTarget) return
|
||||||
|
if (textCell.current != null && textCell.current.contains (element))
|
||||||
|
{
|
||||||
|
event.stopPropagation ()
|
||||||
|
return
|
||||||
|
}
|
||||||
const point = pointFor (element, event.clientY)
|
const point = pointFor (element, event.clientY)
|
||||||
if (point == null || element.closest ('[data-counts-resize]') != null) return
|
if (point == null || element.closest ('[data-counts-resize]') != null) return
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
@@ -321,9 +370,11 @@ const CountsSelectableTable = (
|
|||||||
}
|
}
|
||||||
if (event.key === 'Escape')
|
if (event.key === 'Escape')
|
||||||
{
|
{
|
||||||
|
clearTextSelection ()
|
||||||
setStored (null)
|
setStored (null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (textCell.current != null) return
|
||||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||||
const rows = event.currentTarget.tBodies[0]?.rows
|
const rows = event.currentTarget.tBodies[0]?.rows
|
||||||
if (rows == null || rows.length === 0) return
|
if (rows == null || rows.length === 0) return
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
@apply h-6;
|
@apply h-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.counts-grid > tbody > tr > td[data-counts-text] {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
|
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
|
||||||
@apply bg-white text-slate-900;
|
@apply bg-white text-slate-900;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type CountsVersion = {
|
|||||||
id: number
|
id: number
|
||||||
resourceId: number
|
resourceId: number
|
||||||
versionNo: number
|
versionNo: number
|
||||||
isLatest: boolean
|
latest: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
createdByUser: { id: number; name: string | null } | null
|
createdByUser: { id: number; name: string | null } | null
|
||||||
title?: string | null
|
title?: string | null
|
||||||
@@ -98,7 +98,7 @@ export type CountRecord = Record<string, unknown>
|
|||||||
export const countColumns: Record<string, string> = {
|
export const countColumns: Record<string, string> = {
|
||||||
event_type: 'counts:eventType',
|
event_type: 'counts:eventType',
|
||||||
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
|
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
|
||||||
version_no: 'counts:columns.version', is_latest: 'counts:columns.latest',
|
version_no: 'counts:columns.version', latest: 'counts:columns.latest',
|
||||||
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
||||||
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
|
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
|
||||||
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
||||||
@@ -155,7 +155,7 @@ export const columnsForCounts = (
|
|||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
|
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
|
||||||
...(resource === 'posts' ? ['thumbnail'] : []),
|
...(resource === 'posts' ? ['thumbnail'] : []),
|
||||||
'resource_id', 'version_no', 'is_latest',
|
'resource_id', 'version_no', 'latest',
|
||||||
...(resource === 'posts'
|
...(resource === 'posts'
|
||||||
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
||||||
'original_created_from', 'original_created_before']
|
'original_created_from', 'original_created_before']
|
||||||
@@ -326,6 +326,14 @@ export const normaliseCountsParams = (
|
|||||||
definition?: CountsDefinition,
|
definition?: CountsDefinition,
|
||||||
): URLSearchParams => {
|
): URLSearchParams => {
|
||||||
const params = new URLSearchParams (source)
|
const params = new URLSearchParams (source)
|
||||||
|
for (const [key, value] of params)
|
||||||
|
{
|
||||||
|
if (['columns', 'order', 'sort', 'rows', 'cols', 'axis'].includes (key))
|
||||||
|
params.set (key, value.split (' ').map (token =>
|
||||||
|
token.replace (/^is_latest(?=:|$)/, 'latest')).join (' '))
|
||||||
|
else if (/^(conditions|drill)\.\d+\.field$/.test (key) && value === 'is_latest')
|
||||||
|
params.set (key, 'latest')
|
||||||
|
}
|
||||||
if (!params.has ('definition'))
|
if (!params.has ('definition'))
|
||||||
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
|
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
|
||||||
if (params.get ('definition') === 'latest' && !params.has ('current'))
|
if (params.get ('definition') === 'latest' && !params.has ('current'))
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export const countsCellText = (
|
|||||||
{
|
{
|
||||||
case 'resource_id': return String (row.resourceId)
|
case 'resource_id': return String (row.resourceId)
|
||||||
case 'version_no': return String (row.versionNo)
|
case 'version_no': return String (row.versionNo)
|
||||||
case 'is_latest':
|
case 'latest':
|
||||||
return row.isLatest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
|
return row.latest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
|
||||||
case 'title': return row.title ?? ''
|
case 'title': return row.title ?? ''
|
||||||
case 'url': return row.url ?? ''
|
case 'url': return row.url ?? ''
|
||||||
case 'thumbnail':
|
case 'thumbnail':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const MIN_COUNT_WIDTH = 64
|
|||||||
export const MAX_COUNT_WIDTH = 2000
|
export const MAX_COUNT_WIDTH = 2000
|
||||||
|
|
||||||
export const defaultCountWidths: Record<string, number> = {
|
export const defaultCountWidths: Record<string, number> = {
|
||||||
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, latest: 104,
|
||||||
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
||||||
video_ms: 120, original_created_from: 240,
|
video_ms: 120, original_created_from: 240,
|
||||||
original_created_before: 240, name: 240, names: 480, category: 180,
|
original_created_before: 240, name: 240, names: 480, category: 180,
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ const CountsResourceContent = (
|
|||||||
const [exporting, setExporting] = useState (false)
|
const [exporting, setExporting] = useState (false)
|
||||||
const exportInFlight = useRef (false)
|
const exportInFlight = useRef (false)
|
||||||
const [exportError, setExportError] = useState (false)
|
const [exportError, setExportError] = useState (false)
|
||||||
const { data, dataUpdatedAt, isFetching, isPending, isError,
|
const { data, dataUpdatedAt, isFetching: fetching, isPending: pending, isError: errorFlg,
|
||||||
isPlaceholderData } = useQuery ({
|
isPlaceholderData: placeholder } = useQuery ({
|
||||||
enabled: definition != null && params.has ('timezone') && !running,
|
enabled: definition != null && params.has ('timezone') && !running,
|
||||||
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
||||||
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||||
@@ -104,7 +104,7 @@ const CountsResourceContent = (
|
|||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
applied.current = params
|
applied.current = params
|
||||||
if (data != null && !isPlaceholderData && !isError) retainedParams.current = params
|
if (data != null && !placeholder && !errorFlg) retainedParams.current = params
|
||||||
const pending = pendingRun.current
|
const pending = pendingRun.current
|
||||||
if (pending == null) return
|
if (pending == null) return
|
||||||
if (pending.target === canonical)
|
if (pending.target === canonical)
|
||||||
@@ -118,7 +118,7 @@ const CountsResourceContent = (
|
|||||||
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
||||||
setRunning (false)
|
setRunning (false)
|
||||||
}
|
}
|
||||||
}, [canonical, data, isError, isPlaceholderData, params, queryClient, requestKey])
|
}, [canonical, data, errorFlg, placeholder, params, queryClient, requestKey])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setRunning (false)
|
setRunning (false)
|
||||||
@@ -132,7 +132,7 @@ const CountsResourceContent = (
|
|||||||
}, [locale, queryClient, resource])
|
}, [locale, queryClient, resource])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (running || data == null || isPlaceholderData || isFetching || isError
|
if (running || data == null || placeholder || fetching || errorFlg
|
||||||
|| !params.has ('timezone')) return
|
|| !params.has ('timezone')) return
|
||||||
const next = new URLSearchParams (canonical)
|
const next = new URLSearchParams (canonical)
|
||||||
if (data.executionAt != null && !params.has ('execution_at'))
|
if (data.executionAt != null && !params.has ('execution_at'))
|
||||||
@@ -145,7 +145,7 @@ const CountsResourceContent = (
|
|||||||
}
|
}
|
||||||
if (source.toString () !== next.toString ())
|
if (source.toString () !== next.toString ())
|
||||||
setParams (next, { replace: true, preventScrollReset: true })
|
setParams (next, { replace: true, preventScrollReset: true })
|
||||||
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
|
}, [canonical, data, dataUpdatedAt, errorFlg, fetching, placeholder,
|
||||||
locale, params, queryClient, resource, running, setParams, source])
|
locale, params, queryClient, resource, running, setParams, source])
|
||||||
|
|
||||||
|
|
||||||
@@ -206,11 +206,11 @@ const CountsResourceContent = (
|
|||||||
setRunError (true)
|
setRunError (true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resultParams = isPlaceholderData ? retainedParams.current : params
|
const resultParams = placeholder ? retainedParams.current : params
|
||||||
const resultMode = resultParams.get ('mode') ?? 'summary'
|
const resultMode = resultParams.get ('mode') ?? 'summary'
|
||||||
const waiting = running || (!definitionError && (definition == null
|
const waiting = running || (!definitionError && (definition == null
|
||||||
|| isPending || isFetching || isPlaceholderData
|
|| pending || fetching || placeholder
|
||||||
|| (!params.has ('execution_at') && !isError)))
|
|| (!params.has ('execution_at') && !errorFlg)))
|
||||||
|
|
||||||
|
|
||||||
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||||
@@ -324,7 +324,7 @@ const CountsResourceContent = (
|
|||||||
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||||
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
||||||
resource={resource} applied={params}
|
resource={resource} applied={params}
|
||||||
pending={running || isFetching || !params.has ('execution_at')}
|
pending={running || fetching || !params.has ('execution_at')}
|
||||||
open={open} setOpen={setOpen}
|
open={open} setOpen={setOpen}
|
||||||
reorder={value => update ('order', value)}
|
reorder={value => update ('order', value)}
|
||||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||||
@@ -338,15 +338,15 @@ const CountsResourceContent = (
|
|||||||
actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
{(['csv', 'json'] as const).map (format => <Button key={format}
|
{(['csv', 'json'] as const).map (format => <Button key={format}
|
||||||
type="button" variant="outline" size="sm"
|
type="button" variant="outline" size="sm"
|
||||||
disabled={data == null || isError || isPlaceholderData
|
disabled={data == null || errorFlg || placeholder
|
||||||
|| running || isFetching || exporting || !params.get ('execution_at')}
|
|| running || fetching || exporting || !params.get ('execution_at')}
|
||||||
onClick={() => void download (format)}>
|
onClick={() => void download (format)}>
|
||||||
{label (`exportFormats.${format}`)}</Button>)}
|
{label (`exportFormats.${format}`)}</Button>)}
|
||||||
</div>}/>}
|
</div>}/>}
|
||||||
|
|
||||||
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
</div>
|
</div>
|
||||||
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
{(definitionError || errorFlg || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
aria-busy={waiting}>
|
aria-busy={waiting}>
|
||||||
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||||
@@ -382,11 +382,11 @@ const CountsResourceContent = (
|
|||||||
|
|
||||||
|
|
||||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||||
const { data, isError } = useQuery ({
|
const { data, isError: errorFlg } = useQuery ({
|
||||||
queryKey: countsKeys.definition (resource),
|
queryKey: countsKeys.definition (resource),
|
||||||
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
||||||
return <CountsDefinitionContext.Provider value={data ?? null}>
|
return <CountsDefinitionContext.Provider value={data ?? null}>
|
||||||
<CountsResourceContent resource={resource} definition={data} definitionError={isError}/>
|
<CountsResourceContent resource={resource} definition={data} definitionError={errorFlg}/>
|
||||||
</CountsDefinitionContext.Provider>
|
</CountsDefinitionContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする