このコミットが含まれているのは:
2026-10-27 18:49:58 +09:00
コミット f9f31812a9
22個のファイルの変更、201行の追加、59行の削除
+4 -1
ファイルの表示
@@ -11,7 +11,10 @@
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
## Mandatory naming contract
+4 -1
ファイルの表示
@@ -11,7 +11,10 @@
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
+1 -1
ファイルの表示
@@ -149,7 +149,7 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
# This number deliberately ignores time, identity and content filters to prevent count probes.
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)
total, excluded = model.unscoped.optimizer_hints('MAX_EXECUTION_TIME(10000)').pick(
Arel.sql('COUNT(*)'),
+1 -1
ファイルの表示
@@ -23,7 +23,7 @@ class CountsVersionRepr
@rows.map do |row|
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_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_by_user: row.created_by_user_id &&
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
+5 -5
ファイルの表示
@@ -13,7 +13,7 @@ class CountsCondition
path_labels = labels + [field[:label]]
type = if prefix
CHILD_TYPES.fetch(field[:key], :string)
elsif key == 'is_latest'
elsif key == 'latest'
:boolean
elsif key == 'parents'
:integer
@@ -27,7 +27,7 @@ class CountsCondition
type = :string if field[:children] || type == :json
type = :integer if ['parents', 'members'].include?(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]
entry = { key:, type:, labels: path_labels, nullable:,
multiple: prefix.present? || field[:children].present?,
@@ -204,7 +204,7 @@ class CountsCondition
bound = @connection.quote(point)
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
" 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 ' +
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
"AND newer_parent.created_at <= #{ bound } " +
@@ -218,7 +218,7 @@ class CountsCondition
scalar('tag_snapshot.item', 'tag_id', :integer) +
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
HistoryAccess.sql(TagVersion, 'referenced_tag')
@population.access_sql(TagVersion, 'referenced_tag')
source
end
@@ -242,7 +242,7 @@ class CountsCondition
def source_for(key)
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?
expression = parts.first == 'id' ? 'v.created_by_user_id' :
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
+2 -2
ファイルの表示
@@ -1,6 +1,6 @@
# Only confirmed combinations belong here; all others retain the existing behaviour.
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',
'original_created_before', 'event_type', 'user', 'created_at'].freeze
@@ -31,7 +31,7 @@ class CountsDefaults
'children' => CountsExportProjection.default_children(resource).join(' ') }
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']
{ 'columns' => columns.join(' '), 'order' => columns.join(' '),
'children' => 'aliases.name parents.name', 'nested_json' => '0' }
+3 -2
ファイルの表示
@@ -3,7 +3,7 @@ require 'set'
# Export identifiers are stable; UI translations are supplied separately as column labels.
class CountsExportProjection
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',
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
'original_created_from' => 'original_created_from',
@@ -42,6 +42,7 @@ class CountsExportProjection
attr_reader :columns, :headers
def initialize(params, resource:, summary:, tags:)
params = CountsResourceDefinition.normalise_params(params)
if params.key?('definition')
params = CountsDefaults.for(params.merge('resource' => resource)).merge(params)
end
@@ -144,7 +145,7 @@ class CountsExportProjection
def csv_display(value, key)
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
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("category:#{ value }", value) if key == 'category'
if ['language', 'script'].include?(key) && value.is_a?(Hash)
+2 -1
ファイルの表示
@@ -7,6 +7,7 @@ class CountsExporter
attr_reader :format, :resource
def initialize(params, language_code:)
params = CountsResourceDefinition.normalise_params(params)
if params.key?('definition')
@table_export = CountsTableExport.new(params)
@format = @table_export.format
@@ -66,7 +67,7 @@ class CountsExporter
child_sort: sort_metadata(@params.fetch('child_sort', '')),
columns: @projection.metadata,
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],
totals: page[:totals], coverage: page[:coverage] }
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
+9 -3
ファイルの表示
@@ -2,7 +2,8 @@
class CountsPopulation
attr_reader :definition, :params, :at, :history, :scope, :timezone
def initialize(params)
def initialize(params, authorisation_flg: true)
@authorisation_flg = authorisation_flg
@params = params
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
@history = params.fetch('definition', 'latest') == 'history'
@@ -14,7 +15,7 @@ class CountsPopulation
@scope = definition.model.all
make_population
@scope = HistoryAccess.visible(@scope, 'v')
@scope = HistoryAccess.visible(@scope, 'v') if @authorisation_flg
apply_filters
rescue TZInfo::InvalidTimezoneIdentifier
raise ArgumentError
@@ -56,6 +57,11 @@ class CountsPopulation
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
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
def make_population
@@ -64,7 +70,7 @@ class CountsPopulation
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 AND ' +
HistoryAccess.sql(definition.model, 'previous'))
access_sql(definition.model, 'previous'))
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
unless history
point = timestamp(params['at']) || at
+3 -2
ファイルの表示
@@ -1,7 +1,7 @@
# Read-only history queries. SQL identifiers and expressions are server-owned.
class CountsQuery
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 = {
'posts' => COMMON_COLUMNS + [
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
@@ -15,6 +15,7 @@ class CountsQuery
attr_reader :page, :limit, :mode, :axis
def initialize(scope, resource, params, latest_expression: nil)
params = CountsResourceDefinition.normalise_params(params)
@table = scope.klass.table_name
@resource = resource
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
@@ -117,7 +118,7 @@ class CountsQuery
def detail_expressions
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
'version_no' => ["#{ @table }.version_no"],
'is_latest' => ["(#{ latest_expression })"],
'latest' => ["(#{ latest_expression })"],
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
'created_at' => ["#{ @table }.created_at"] }
if @resource == 'posts'
+19 -2
ファイルの表示
@@ -1,5 +1,22 @@
# Server-owned version identities, attributes and field types. No client SQL is accepted.
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,
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
DEFINITIONS = {
@@ -35,7 +52,7 @@ class CountsResourceDefinition
interval: ['range'] }.freeze
LABELS = {
'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',
'thumbnail' => 'counts:columns.thumbnailDisplay',
'thumbnail_base' => 'counts:columns.thumbnailURL',
@@ -101,7 +118,7 @@ class CountsResourceDefinition
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
else fields.keys - COMMON.keys
end
keys = ['resource_id', 'version_no', 'is_latest'] + (content - ['thumbnail']) +
keys = ['resource_id', 'version_no', 'latest'] + (content - ['thumbnail']) +
['event_type', 'user', 'created_at']
keys.unshift('thumbnail') if content.include?('thumbnail')
keys.map do |key|
+3 -2
ファイルの表示
@@ -7,6 +7,7 @@ class CountsTableExport
attr_reader :format, :resource
def initialize(params)
params = CountsResourceDefinition.normalise_params(params)
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@@ -47,7 +48,7 @@ class CountsTableExport
if ['posts', 'tags'].include?(resource)
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
else
allowed = @query.population.definition.fields.keys + ['is_latest']
allowed = @query.population.definition.fields.keys + ['latest']
raise ArgumentError if (@columns - allowed).any?
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
end
@@ -305,7 +306,7 @@ class CountsTableExport
def csv_logical(key, value)
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)
return @query.population.timezone.utc_to_local(value.utc).iso8601
end
+52 -11
ファイルの表示
@@ -9,6 +9,7 @@ class CountsTableQuery
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
params = CountsResourceDefinition.normalise_params(params)
QueryState.validate!(params, KEYS + ['measures'])
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) }
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 == 'tags' ? ['parents', 'deprecated'] : [])
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
@@ -43,9 +44,10 @@ class CountsTableQuery
ApplicationRecord.transaction do
result = metadata.merge(mode == 'summary' ? summary : details)
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
totals = output_totals(visible)
result.merge(visible_output_count: visible,
output_count: visible,
output_count_scope: 'authorised_results',
**totals,
output_count_scope: 'before_authorisation',
output_count_unit: 'table_body_rows',
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
result[:group_count] > limit)
@@ -54,12 +56,8 @@ class CountsTableQuery
def metadata
@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(
excluded_count: exclusions_known ? 0 : nil,
excluded_count_scope: exclusions_known ? 'current_query' : 'unavailable_private_history',
excluded_count: nil, excluded_count_scope: 'current_query',
population_count_scope: 'all_recorded_versions',
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest',
@@ -186,7 +184,7 @@ class CountsTableQuery
[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,
'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
@@ -217,12 +215,55 @@ class CountsTableQuery
"(SELECT COUNT(*) FROM (SELECT #{ keys } FROM cells GROUP BY #{ keys }) subtotal_rows)"
end
expressions = [total.to_s, '1'] + subtotals
@output_row_count ||= @connection.select_value(<<~SQL).to_i
@connection.select_value(<<~SQL).to_i
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ #{ expressions.join(' + ') } AS output_rows
SQL
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 limited_columns(prefix)
@@ -284,7 +325,7 @@ class CountsTableQuery
end
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' ?
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' }
field = aliases.fetch(key, key)
+4 -1
ファイルの表示
@@ -11,7 +11,10 @@
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
+2 -2
ファイルの表示
@@ -47,7 +47,7 @@ const CountsControls = (
const mode = draft.get ('mode') ?? 'summary'
const columns = selectedCountColumns (resource, draft, definition)
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
&& !countsNestedIsScalar (field.children, field.key, draft)
&& field.children.some (child => selectedCountChildren (draft)
@@ -163,7 +163,7 @@ const CountsControls = (
onChange={event => displayThumbnail (event.target.checked)}/>
{label ('columns.thumbnailDisplay')}
</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'}
onChange={event => {
update ('nested_json', event.target.checked ? '1' : '0')
+1 -1
ファイルの表示
@@ -87,7 +87,7 @@ const CountsResults = (
case 'thumbnail_base':
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
{value}</a>
case 'is_latest':
case 'latest':
return <LocalisedText language={message ('latestValue').language}>
{value}</LocalisedText>
case 'user':
+51
ファイルの表示
@@ -53,6 +53,7 @@ const CountsSelectableTable = (
) => {
const id = useId ()
const root = useRef<HTMLTableElement> (null)
const textCell = useRef<HTMLTableCellElement | null> (null)
const dragging = useRef (false)
const touchArmed = useRef (false)
const touchPress = useRef<{ x: number; y: number
@@ -64,6 +65,15 @@ const CountsSelectableTable = (
const selection = stored?.identity === identity ? stored : null
const layout = useCountsTableLayout ()
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 : {
top: Math.min (selection.anchor.row, selection.end.row),
bottom: Math.max (selection.anchor.row, selection.end.row),
@@ -198,6 +208,14 @@ const CountsSelectableTable = (
closeMenu ()
}
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
const payload = clipboard ()
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-offset-[-1px]', className)}
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 => {
if ((event.target as Element).closest ('table') !== event.currentTarget
|| !suppressClick.current) return
@@ -243,6 +281,12 @@ const CountsSelectableTable = (
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
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 instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
{
@@ -300,6 +344,11 @@ const CountsSelectableTable = (
onContextMenu={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
if (textCell.current != null && textCell.current.contains (element))
{
event.stopPropagation ()
return
}
const point = pointFor (element, event.clientY)
if (point == null || element.closest ('[data-counts-resize]') != null) return
event.preventDefault ()
@@ -321,9 +370,11 @@ const CountsSelectableTable = (
}
if (event.key === 'Escape')
{
clearTextSelection ()
setStored (null)
return
}
if (textCell.current != null) return
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
const rows = event.currentTarget.tBodies[0]?.rows
if (rows == null || rows.length === 0) return
+6
ファイルの表示
@@ -2,6 +2,12 @@
@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]) {
@apply bg-white text-slate-900;
}
+11 -3
ファイルの表示
@@ -27,7 +27,7 @@ export type CountsVersion = {
id: number
resourceId: number
versionNo: number
isLatest: boolean
latest: boolean
createdAt: string
createdByUser: { id: number; name: string | null } | null
title?: string | null
@@ -98,7 +98,7 @@ export type CountRecord = Record<string, unknown>
export const countColumns: Record<string, string> = {
event_type: 'counts:eventType',
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',
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
@@ -155,7 +155,7 @@ export const columnsForCounts = (
definition?: CountsDefinition,
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
...(resource === 'posts' ? ['thumbnail'] : []),
'resource_id', 'version_no', 'is_latest',
'resource_id', 'version_no', 'latest',
...(resource === 'posts'
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'original_created_from', 'original_created_before']
@@ -326,6 +326,14 @@ export const normaliseCountsParams = (
definition?: CountsDefinition,
): URLSearchParams => {
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'))
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
if (params.get ('definition') === 'latest' && !params.has ('current'))
+2 -2
ファイルの表示
@@ -24,8 +24,8 @@ export const countsCellText = (
{
case 'resource_id': return String (row.resourceId)
case 'version_no': return String (row.versionNo)
case 'is_latest':
return row.isLatest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
case 'latest':
return row.latest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
case 'title': return row.title ?? ''
case 'url': return row.url ?? ''
case 'thumbnail':
+1 -1
ファイルの表示
@@ -4,7 +4,7 @@ export const MIN_COUNT_WIDTH = 64
export const MAX_COUNT_WIDTH = 2000
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,
video_ms: 120, original_created_from: 240,
original_created_before: 240, name: 240, names: 480, category: 180,
+15 -15
ファイルの表示
@@ -91,8 +91,8 @@ const CountsResourceContent = (
const [exporting, setExporting] = useState (false)
const exportInFlight = useRef (false)
const [exportError, setExportError] = useState (false)
const { data, dataUpdatedAt, isFetching, isPending, isError,
isPlaceholderData } = useQuery ({
const { data, dataUpdatedAt, isFetching: fetching, isPending: pending, isError: errorFlg,
isPlaceholderData: placeholder } = useQuery ({
enabled: definition != null && params.has ('timezone') && !running,
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
@@ -104,7 +104,7 @@ const CountsResourceContent = (
useEffect (() => {
applied.current = params
if (data != null && !isPlaceholderData && !isError) retainedParams.current = params
if (data != null && !placeholder && !errorFlg) retainedParams.current = params
const pending = pendingRun.current
if (pending == null) return
if (pending.target === canonical)
@@ -118,7 +118,7 @@ const CountsResourceContent = (
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
setRunning (false)
}
}, [canonical, data, isError, isPlaceholderData, params, queryClient, requestKey])
}, [canonical, data, errorFlg, placeholder, params, queryClient, requestKey])
useEffect (() => {
setRunning (false)
@@ -132,7 +132,7 @@ const CountsResourceContent = (
}, [locale, queryClient, resource])
useEffect (() => {
if (running || data == null || isPlaceholderData || isFetching || isError
if (running || data == null || placeholder || fetching || errorFlg
|| !params.has ('timezone')) return
const next = new URLSearchParams (canonical)
if (data.executionAt != null && !params.has ('execution_at'))
@@ -145,7 +145,7 @@ const CountsResourceContent = (
}
if (source.toString () !== next.toString ())
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])
@@ -206,11 +206,11 @@ const CountsResourceContent = (
setRunError (true)
}
}
const resultParams = isPlaceholderData ? retainedParams.current : params
const resultParams = placeholder ? retainedParams.current : params
const resultMode = resultParams.get ('mode') ?? 'summary'
const waiting = running || (!definitionError && (definition == null
|| isPending || isFetching || isPlaceholderData
|| (!params.has ('execution_at') && !isError)))
|| pending || fetching || placeholder
|| (!params.has ('execution_at') && !errorFlg)))
const update = (key: string, value: string, extra?: Record<string, string>) => {
@@ -324,7 +324,7 @@ const CountsResourceContent = (
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
resource={resource} applied={params}
pending={running || isFetching || !params.has ('execution_at')}
pending={running || fetching || !params.has ('execution_at')}
open={open} setOpen={setOpen}
reorder={value => update ('order', value)}
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">
{(['csv', 'json'] as const).map (format => <Button key={format}
type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData
|| running || isFetching || exporting || !params.get ('execution_at')}
disabled={data == null || errorFlg || placeholder
|| running || fetching || exporting || !params.get ('execution_at')}
onClick={() => void download (format)}>
{label (`exportFormats.${format}`)}</Button>)}
</div>}/>}
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
</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"
aria-busy={waiting}>
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
@@ -382,11 +382,11 @@ const CountsResourceContent = (
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const { data, isError } = useQuery ({
const { data, isError: errorFlg } = useQuery ({
queryKey: countsKeys.definition (resource),
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
return <CountsDefinitionContext.Provider value={data ?? null}>
<CountsResourceContent resource={resource} definition={data} definitionError={isError}/>
<CountsResourceContent resource={resource} definition={data} definitionError={errorFlg}/>
</CountsDefinitionContext.Provider>
}