コミットを比較

...
7 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 8642225b94 #434 2026-10-25 23:08:48 +09:00
みてるぞ 96a5bb8601 #434 2026-10-25 22:16:20 +09:00
みてるぞ e71663bd22 #434 2026-10-25 16:47:05 +09:00
みてるぞ 52a8d62be5 #434 2026-10-25 16:25:04 +09:00
みてるぞ 3e05757820 #434 2026-10-25 16:00:37 +09:00
みてるぞ d411b3b40f #434 2026-10-25 14:53:35 +09:00
みてるぞ 304090f717 #434 2026-10-25 14:14:55 +09:00
46個のファイルの変更、4041行の追加、69行の削除
+17 -4
ファイルの表示
@@ -573,10 +573,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working within the permitted
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コードも対象とする。
既存のテスト差分を勝手に修正・差戻ししない。
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
@@ -631,6 +631,19 @@ and layout reuse, follow `frontend/AGENTS.md`.
## Frontend rules
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
+21
ファイルの表示
@@ -0,0 +1,21 @@
module CountsListing
extend ActiveSupport::Concern
def index
locale = resolve_locale!
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
.merge('locale' => locale.code))
result = { count: query.count, page: query.page, limit: query.limit,
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
if query.mode == 'summary'
result.merge!(query.summary)
else
result[:versions] = CountsVersionRepr.many(
query.details, resource:, language_code: locale.language_code)
end
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
+20
ファイルの表示
@@ -0,0 +1,20 @@
class CountsExportsController < ApplicationController
wrap_parameters false
def index
locale = resolve_locale!
exporter = CountsExporter.new(request.request_parameters.merge(request.query_parameters),
language_code: locale.language_code)
response.headers['Content-Type'] = exporter.format == 'json' ?
'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
response.headers['Content-Disposition'] =
"attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\""
response.headers['Cache-Control'] = 'private, no-store'
response.headers['X-Accel-Buffering'] = 'no'
# Rack::ETag otherwise materialises the response to calculate a digest.
response.headers['Last-Modified'] = Time.current.httpdate
self.response_body = exporter.body
rescue ArgumentError, TypeError, JSON::ParserError
head :bad_request
end
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class CountsPostsController < PostVersionsController
include CountsListing
end
+10
ファイルの表示
@@ -0,0 +1,10 @@
class CountsStatesController < ApplicationController
def index
locale = resolve_locale!
result = CountsStateQuery.new(request.query_parameters,
language_code: locale.language_code).call
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class CountsTagsController < TagVersionsController
include CountsListing
end
+13 -15
ファイルの表示
@@ -14,21 +14,7 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = version_scope
q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
@@ -51,6 +37,18 @@ class PostVersionsController < ApplicationController
private
def version_scope
PostVersion.with_previous_version
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
end
def serialise_versions rows, language_code
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
+15 -11
ファイルの表示
@@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController
offset = (page - 1) * limit
q = TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = version_scope
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -34,6 +24,20 @@ class TagVersionsController < ApplicationController
private
def version_scope
TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
end
def serialise_versions rows, language_code
rows = rows.to_a
tag_ids = rows.map(&:tag_id).uniq
+5
ファイルの表示
@@ -1,6 +1,11 @@
class PostVersion < ApplicationRecord
include VersionRecord
scope :with_previous_version, -> {
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
'AND prev.version_no = post_versions.version_no - 1')
}
belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
+103
ファイルの表示
@@ -0,0 +1,103 @@
# Counts uses recorded values only; history/diff endpoints retain their own contract.
class CountsVersionRepr
def self.many(rows, resource:, language_code:, at: nil, inclusive: true)
new(rows.to_a, resource:, language_code:, at:, inclusive:).serialise
end
def initialize(rows, resource:, language_code:, at:, inclusive:)
@rows = rows
@at = at
@inclusive = inclusive
@resource = resource
@language = language_code
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
@languages = master_names(Language)
@scripts = master_names(Script)
@tag_versions = {}
load_tag_versions if resource == 'posts'
end
def serialise
@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,
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] } }
common.merge(@resource == 'posts' ? post(row) : tag(row))
end
end
private
def master_names(model)
model.select('code AS counts_code', :name).to_h do |record|
[record.attributes['counts_code'], record.name]
end
end
def json(value)
value.is_a?(String) ? JSON.parse(value) : value
end
def load_tag_versions
pairs = @rows.flat_map { Array(json(_1.tags_json)) }
.filter_map { |entry| [entry['tag_id'], entry['version_no']] if entry['tag_id'] }
.uniq
pairs.each_slice(500) do |batch|
placeholders = batch.map { '(?, ?)' }.join(', ')
scope = TagVersion.all
if @at
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
end
scope.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
@tag_versions[[version.tag_id, version.version_no]] = version
end
end
end
def names(value)
(json(value) || {}).sort.map do |language, entry|
aliases = entry.fetch('aliases', []).map do |alias_entry|
alias_entry.merge('script_name' => @scripts[alias_entry['script_code']])
end
{ language_code: language, language_name: @languages[language],
script_code: entry['script_code'], script_name: @scripts[entry['script_code']],
name: entry['name'], auto_generated: entry['auto_generated'], aliases: }
end
end
def post(row)
snapshots = Array(json(row.tags_json)).map do |entry|
version = @tag_versions[[entry['tag_id'], entry['version_no']]]
{ id: entry['tag_id'] || entry['external_tag_id'],
kind: entry['tag_id'] ? 'internal' : 'external',
tag_id: entry['tag_id'], external_tag_id: entry['external_tag_id'],
version_no: entry['version_no'], name: entry['name'] || version&.name,
category: entry['category'] || version&.category,
sections: entry.fetch('sections', []), names: version ? names(version.names) : [] }
end
{ title: row.title, url: row.url, thumbnail_base: row.thumbnail_base,
tags: snapshots, parents: row.parent_post_ids.to_s.split.map { { id: _1.to_i } },
video_ms: row.video_ms,
original_created_from: row.original_created_from&.iso8601,
original_created_before: row.original_created_before&.iso8601 }
end
def tag(row)
recorded_names = names(row.names)
selected = recorded_names.find { _1[:language_code] == @language }
aliases = recorded_names.flat_map do |entry|
entry[:aliases].map {
_1.merge('language_code' => entry[:language_code],
'language_name' => entry[:language_name])
}
end
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
category: row.category, aliases:,
parents: row.parent_tag_ids.to_s.split.map { { id: _1.to_i } },
deprecated: row.deprecated_at&.iso8601 }
end
end
+229
ファイルの表示
@@ -0,0 +1,229 @@
require 'set'
# Export identifiers are stable; UI translations are supplied separately as column labels.
class CountsExportProjection
ROOT_KEYS = { 'resource_id' => 'resource_id', 'version_no' => 'version_no',
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
'original_created_at' => 'original_created_at',
'original_created_from' => 'original_created_from',
'original_created_before' => 'original_created_before',
'user' => 'created_by_user', 'created_at' => 'created_at',
'name' => 'name', 'names' => 'names', 'category' => 'category',
'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze
CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind',
'name' => 'name', 'category' => 'category', 'sections' => 'sections',
'names' => 'names', 'aliases' => 'aliases',
'languageCode' => 'language', 'scriptCode' => 'script',
'autoGenerated' => 'auto_generated' }.freeze
NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze
CHILDREN = {
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
'names' => NAME_FIELDS,
'aliases' => ['languageCode', 'name', 'scriptCode'],
'parents' => ['id'] }.freeze
attr_reader :columns, :headers
def initialize(params, resource:, summary:, tags:)
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) }
@summary = summary
allowed = if summary
tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count']
else
CountsQuery::COLUMNS.fetch(resource)
end
@columns = params.fetch('columns', allowed.join(',')).split(',')
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
unless (@headers.is_a?(Array) && @headers.length == @columns.length &&
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
raise ArgumentError
end
@order = params.fetch('order', '').split(',')
@sorts = params.fetch('child_sort', '').split(',').map do |entry|
parts = entry.split(':', -1)
raise ArgumentError unless parts.length == 2
path, direction = parts
raise ArgumentError unless ['asc', 'desc'].include?(direction)
[path, direction]
end
@selected = params.key?('children') ? params['children'].split(',').to_set : nil
valid = CHILDREN.flat_map do |key, fields|
paths(fields, key)
end
raise ArgumentError if @selected && (@selected.to_a - valid).any?
allowed_sorts = valid.map do |path|
parts = path.split('.')
field = parts.pop
"#{ parts.join('.') }/#{ field }"
end
if ((@sorts.map(&:first) - allowed_sorts).any? ||
@sorts.map(&:first).uniq.length != @sorts.length)
raise ArgumentError
end
end
def metadata
@columns.each_with_index.map do |key, index|
{ key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] }
end
end
def project(row)
@columns.to_h do |key|
if @summary
value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym]
[key, value]
else
value = case key
when 'thumbnail' then row[:thumbnail_base]
when 'original_created_at'
{ from: row[:original_created_from], before: row[:original_created_before] }
else row[ROOT_KEYS.fetch(key).to_sym]
end
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
[ROOT_KEYS.fetch(key), value]
end
end
end
def csv_record(record, row)
record.map do |key, value|
key == 'group' ? row[:name] || row[:key] : csv_display(value, key)
end
end
private
def valid_label?(key, value)
key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300
end
def csv_display(value, key)
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
return @labels.fetch("category:#{ value }", value) if key == 'category'
if ['language', 'script'].include?(key) && value.is_a?(Hash)
return @labels.fetch("#{ key }:#{ value[:code] }",
value[:name] || @labels.fetch('notRegistered', ''))
end
return @labels.fetch("boolean:#{ value }", value) if value == true || value == false
return value.map { csv_display(_1, key) } if value.is_a?(Array)
if value.is_a?(Hash)
return value.to_h do |field, item|
[@labels.fetch(field.to_s, field), csv_display(item, field.to_s)]
end
end
value
end
def fields_for(key)
return NAME_FIELDS if key == 'names'
return ['name', 'scriptCode'] if key == 'aliases'
[]
end
def paths(fields, prefix)
fields.flat_map do |key|
path = "#{ prefix }.#{ key }"
[path, *paths(fields_for(key), path)]
end
end
def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym]
def named_code(row, kind)
code = fetch(row, "#{ kind }_code")
name = fetch(row, "#{ kind }_name")
{ code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil }
end
def stable(value)
case value
when Hash
value.stringify_keys.sort.to_h.transform_values { stable(_1) }
when Array
value.map { stable(_1) }
else
value
end
end
def sort_value(row, key)
if key == 'languageCode'
language = named_code(row, 'language')
return @labels.fetch("language:#{ language[:code] }", language[:name])
end
return named_code(row, 'script')[:name] if key == 'scriptCode'
fetch(row, CHILD_KEYS.fetch(key))
end
def comparison(left, right)
if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length
return left.length <=> right.length
end
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
if [true, false].include?(left) && [true, false].include?(right)
return (left ? 1 : 0) <=> (right ? 1 : 0)
end
a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s
b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s
a <=> b
end
def sorted(rows, prefix, fields)
sorts = @sorts.filter_map do |path, direction|
parent, key = path.split('/', 2)
next unless parent == prefix
raise ArgumentError unless fields.include?(key)
[key, direction]
end
rows.each_with_index.sort do |(left, a), (right, b)|
result = 0
sorts.each do |key, direction|
first = sort_value(left, key)
last = sort_value(right, key)
result = if first.nil? || last.nil?
first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1)
else
comparison(first, last) * (direction == 'asc' ? 1 : -1)
end
break unless result.zero?
end
result.zero? ? a <=> b : result
end.map(&:first)
end
def nested(rows, fields, prefix)
keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") }
ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq
.filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } }
sorted(rows, prefix, fields).map do |row|
ordered.to_h do |key|
stable = CHILD_KEYS.fetch(key)
value = case key
when 'languageCode' then named_code(row, 'language')
when 'scriptCode' then named_code(row, 'script')
else fetch(row, stable)
end
if fields_for(key).any?
value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }")
end
[stable, value]
end
end
end
end
+138
ファイルの表示
@@ -0,0 +1,138 @@
require 'csv'
# One read view and a fixed point for the entire download; only one page is held in memory.
class CountsExporter
EXTRA_KEYS = ['basis', 'format', 'resource', 'headers', 'children', 'order',
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
attr_reader :format, :resource
def initialize(params, language_code:)
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
raise ArgumentError if (params.keys - allowed).any?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@language = language_code
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@history = params.fetch('basis', 'state') == 'history'
raise ArgumentError unless ['json', 'csv'].include?(format)
raise ArgumentError unless ['posts', 'tags'].include?(resource)
raise ArgumentError unless ['summary', 'details'].include?(@mode)
raise ArgumentError unless ['state', 'history'].include?(params.fetch('basis', 'state'))
@query_params = params.except(*EXTRA_KEYS, 'columns', 'page', 'limit')
.merge('mode' => @mode, 'page' => '1', 'limit' => '100')
point = CountsStateQuery.new(@query_params.slice('at').merge('resource' => resource),
language_code: @language).at
@query_params['at'] = point.iso8601(6)
@query_params['axis'] ||= !@history && resource == 'posts' ? 'tags' : 'month'
@projection = CountsExportProjection.new(params, resource:,
summary: @mode == 'summary',
tags: !@history && @query_params['axis'] == 'tags')
if @history
@query_params.delete('kind')
history_query
else
@query_params.delete('latest')
@state_query = CountsStateQuery.new(@query_params.merge('resource' => resource),
language_code: @language).validate_query!
end
end
def body
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
first_row = true
started = false
pages do |page|
unless started
meta = { resource:, mode: @mode, basis: @history ? 'history' : 'state',
at: @query_params['at'], boundary: 'inclusive',
filters: @query_params.except('sort', 'page', 'limit', 'mode'),
sort: export_sort,
child_sort: sort_metadata(@params.fetch('child_sort', '')),
columns: @projection.metadata,
children: @params['children']&.split(','),
field_order: @params.fetch('order', '').split(','), timezone: 'UTC',
count: @mode == 'summary' ? page[:group_count] : page[:count],
totals: page[:totals], coverage: page[:coverage] }
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
output << CSV.generate_line(
@projection.headers.map { csv_value(_1) }, row_sep: "\r\n")
end
started = true
end
(page[:versions] || page[:state_rows] || page[:groups] || []).each do |row|
record = @projection.project(row)
if format == 'json'
output << ',' unless first_row
output << record.to_json
else
values = @projection.csv_record(record, row).map { csv_value(_1) }
output << CSV.generate_line(values, row_sep: "\r\n")
end
first_row = false
end
end
output << ']}' if format == 'json'
end
end
end
private
def export_sort
sort_metadata(@query_params.fetch('sort', default_sort))
end
def sort_metadata(value)
value.split(',').map do |entry|
key, direction = entry.split(':', 2)
{ key:, direction: }
end
end
def default_sort
return 'created_at:desc' if @mode == 'details'
!@history && @query_params['axis'] == 'tags' ? 'count:desc' : 'group:asc'
end
def history_query
scope = resource == 'posts' ? PostVersion.with_previous_version : TagVersion.all
CountsQuery.new(scope, resource, @query_params)
end
def pages
unless @history
@state_query.each_page { yield _1 }
return
end
query = history_query
total = @mode == 'summary' ? nil : query.count
loop do
result = if @mode == 'summary'
query.summary(include_count: @query_params['page'] == '1')
else
{ count: total,
versions: CountsVersionRepr.many(query.details, resource:,
language_code: @language,
at: Time.iso8601(@query_params['at'])) }
end
total ||= result[:group_count]
yield result
break if @query_params['page'].to_i * 100 >= total
@query_params['page'] = (@query_params['page'].to_i + 1).to_s
query = history_query
end
end
def csv_value(value)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
+208
ファイルの表示
@@ -0,0 +1,208 @@
# 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', 'user', 'created_at'].freeze
COLUMNS = {
'posts' => COMMON_COLUMNS + [
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
'original_created_at', 'original_created_from', 'original_created_before'],
'tags' => COMMON_COLUMNS + [
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
KEYS = [
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze
attr_reader :page, :limit, :mode, :axis
def initialize(scope, resource, params, latest_expression: nil)
@table = scope.klass.table_name
@resource = resource
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
@params = params
@latest_expression = latest_expression
@scope = scope.except(:select, :order).joins(
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@mode = params.fetch('mode', 'summary')
@axis = params.fetch('axis', 'month')
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
@page = integer('page', 1)
@limit = integer('limit', 20)
raise ArgumentError if limit > 100 || page > 1_000_000
columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
raise ArgumentError
end
@sort = parse_sort
apply_filters
end
def relation = @scope
def count = @scope.count
def latest_expression
return @latest_expression if @latest_expression
at = timestamp('at')
cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : ''
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM #{ @table } newer
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
AND newer.version_no > #{ @table }.version_no #{ cutoff })
SQL
end
def details
@scope.select("#{ @table }.*", Arel.sql("(#{ latest_expression }) AS counts_latest"))
.order(Arel.sql(order_sql(detail_expressions, ["#{ @table }.id DESC"])))
.limit(limit).offset((page - 1) * limit)
end
def summary(include_count: true)
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
"#{ @table }.created_by_user_id"
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
sql = @scope.group(Arel.sql(expression)).select(Arel.sql(
"#{ expression } AS group_key, COUNT(*) AS record_count, " +
"#{ group_order } AS group_name")).to_sql
connection = @scope.klass.connection
source = "FROM (#{ sql }) counts_groups"
groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
rows = connection.select_all(
"SELECT group_key, group_name, record_count #{ source } " +
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
{ group_count: groups&.to_i,
groups: rows.map do |row|
{ key: row['group_key'],
name: axis == 'user' ? row['group_name'] : nil,
count: row['record_count'].to_i }
end }
end
private
def parse_sort
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
return [] if value.empty?
values = value.split(',', -1).map { _1.split(':', -1) }
allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count']
invalid = values.any? do |key, direction, *extra|
extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction)
end
if (values.empty? || values.length > allowed.length || invalid ||
values.map(&:first).uniq.length != values.length)
raise ArgumentError
end
values
end
# NULL always sorts last, including descending keys. The final key is unique.
def order_sql(expressions, final)
(@sort.flat_map do |key, direction|
expressions.fetch(key).flat_map do |expression|
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
end + final).join(', ')
end
def detail_expressions
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
'version_no' => ["#{ @table }.version_no"],
'is_latest' => ["(#{ latest_expression })"],
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
'created_at' => ["#{ @table }.created_at"] }
if @resource == 'posts'
fields = [
'title', 'url', 'thumbnail_base', 'video_ms',
'original_created_from', 'original_created_before']
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
'original_created_at' => [
'post_versions.original_created_from', 'post_versions.original_created_before'],
'tags' => json_order('post_versions.tags_json'),
'parents' => list_order('post_versions.parent_post_ids'))
else
name = "JSON_UNQUOTE(JSON_EXTRACT(tag_versions.names, #{ locale_name_path }))"
common.merge(
'name' => ["COALESCE(#{ name }, tag_versions.name)"],
'names' => json_order('tag_versions.names'),
'category' => ['tag_versions.category'],
'aliases' => json_order(
"JSON_EXTRACT(tag_versions.names, '$.*.aliases[*]')"),
'parents' => list_order('tag_versions.parent_tag_ids'),
'deprecated' => ['tag_versions.deprecated_at'])
end
end
def locale_name_path
language = Locale.find_by(code: @params.fetch('locale', 'ja'))&.language_code || 'ja'
path = "$.#{ JSON.generate(language) }.name"
@scope.klass.connection.quote(path)
end
# Multi-valued parent cells sort by cardinality, then binary snapshot text.
def json_order(column)
[
"COALESCE(JSON_LENGTH(#{ column }), 0)",
"COALESCE(CAST(#{ column } AS CHAR), '[]') COLLATE utf8mb4_bin"]
end
def list_order(column)
[
"CASE WHEN #{ column } = '' THEN 0 ELSE " +
"1 + LENGTH(#{ column }) - LENGTH(REPLACE(#{ column }, ' ', '')) END",
"#{ column } COLLATE utf8mb4_bin"]
end
def integer(key, default = nil)
value = @params[key].presence
return default if value.nil? && !default.nil?
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
raise ArgumentError unless value.match?(pattern)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
end
def apply_filters
at = timestamp('at')
first = timestamp('from')
last = timestamp('before')
raise ArgumentError if first && last && first >= last
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
@scope = @scope.where("#{ @table }.created_at <= ?", at) if at
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
if @params['resource_id'].present?
@scope = @scope.where(@table => { @id_column => integer('resource_id') })
end
if @params['user_id'].present?
user = @params['user_id'] == 'none' ? nil : integer('user_id')
@scope = @scope.where(@table => { created_by_user_id: user })
end
return unless @params['tag'].present?
raise ArgumentError unless @resource == 'posts'
id = integer('tag')
@scope = @scope.where(
"JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " +
"JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id)
end
end
+366
ファイルの表示
@@ -0,0 +1,366 @@
# Restores one recorded version per resource before applying record filters.
class CountsStateQuery
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
'kind', 'tag', 'page', 'limit', 'sort', 'axis', 'user_id', 'resource_id'].freeze
MAX_PERIODS = 366
attr_reader :at
def initialize(params, language_code:)
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@connection = ApplicationRecord.connection
@language = language_code
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@inclusive = ['summary', 'details'].include?(@mode)
@axis = params.fetch('axis', @resource == 'posts' ? 'tags' : 'month')
@kind = params.fetch('kind', 'internal')
@grain = params.fetch('grain', 'month')
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
raise ArgumentError unless ['summary', 'details', 'state', 'trend', 'snapshot'].include?(@mode)
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
raise ArgumentError unless ['tags', 'month', 'user'].include?(@axis)
if @mode == 'summary' && @axis == 'tags' && @resource != 'posts'
raise ArgumentError
end
@page = integer('page', 1)
@limit = integer('limit', 20)
@tag = integer('tag') if params['tag'].present?
raise ArgumentError if @limit > 100 || @page > 1_000_000
raise ArgumentError if @tag && @kind == 'all'
raise ArgumentError if @mode == 'trend' && !@tag
@now = Time.current.utc
@at = timestamp('at') || @now
raise ArgumentError if @at > @now
end
def call(metadata: true)
@metadata = metadata
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
ApplicationRecord.transaction do
unless @history_loaded
@post_start = PostVersion.minimum(:created_at)
@tag_start = TagVersion.minimum(:created_at)
@history_loaded = true
end
result = { page: @page, limit: @limit, at: @at.iso8601(6),
coverage: { basis: 'recorded_snapshots', exact: false,
post_history_from: @post_start&.iso8601(6),
tag_history_from: @tag_start&.iso8601(6) } }
case @mode
when 'trend'
result.merge(trend)
when 'summary'
result.merge(metadata ? { totals: totals } : {}).merge(summary)
when 'details', 'snapshot'
result.merge(metadata ? { totals: totals } : {}).merge(details)
else
result.merge(metadata ? { totals: totals } : {}).merge(listing)
end
end
end
def each_page
return enum_for(:each_page) unless block_given?
@page = 1
first = call
yield first
total = ['summary', 'state', 'trend'].include?(@mode) ? first[:group_count] : first[:count]
while @page * @limit < total
@page += 1
yield call(metadata: false)
end
end
def validate_query!
filtered_scope(@resource)
case @mode
when 'summary'
if @axis == 'tags'
order_sql({ 'name' => 'name', 'count' => 'post_count', 'tag_id' => 'tag_id',
'kind' => 'kind', 'deprecated' => 'deprecated_at' }, 'count:desc', [])
else
record_query('summary')
end
when 'details', 'snapshot'
record_query('details')
end
self
end
private
def quote(value) = @connection.quote(value)
def integer(key, default = nil)
value = @params[key]
return default if value.blank? && default
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
raise ArgumentError unless value.match?(pattern)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value).utc
end
# Version numbers order equal-timestamp writes. Do not filter tags before this selection.
def latest_condition(table, row, boundary)
key = table == 'post_versions' ? 'post_id' : 'tag_id'
<<~SQL.squish
#{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS (
SELECT 1 FROM #{ table } newer
WHERE newer.#{ key } = #{ row }.#{ key }
AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary }
AND newer.version_no > #{ row }.version_no)
SQL
end
def state_sql(table, boundary, live: false)
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) }"
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
end
def membership(row)
key = @kind == 'external' ? 'external_tag_id' : 'tag_id'
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ key }', #{ @tag }))"
end
# Deprecation keeps a tag in existence. Only an explicit discard removes it.
def tag_visible(boundary)
return '1 = 1' if @kind == 'external'
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM tag_versions t
WHERE t.tag_id = #{ @tag } AND t.event_type = 'discard'
AND #{ latest_condition('tag_versions', 't', boundary) })
SQL
end
def ctes
boundary = quote(@at)
<<~SQL
WITH post_at AS (#{ filtered_scope('posts').to_sql }),
tag_at AS (#{ state_sql('tag_versions', boundary) }),
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
pairs AS (
SELECT DISTINCT p.post_id,
CASE WHEN entry.tag_id IS NULL THEN 'external' ELSE 'internal' END AS kind,
COALESCE(entry.tag_id, entry.external_tag_id) AS tag_id
FROM post_at p
JOIN JSON_TABLE(CAST(p.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY ERROR ON ERROR,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY ERROR ON ERROR
)) entry
LEFT JOIN tag_at t ON t.tag_id = entry.tag_id
WHERE COALESCE(entry.tag_id, entry.external_tag_id) IS NOT NULL
AND (t.event_type IS NULL OR t.event_type <> 'discard')
)
SQL
end
def totals
row = @connection.select_one(<<~SQL)
#{ ctes }
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
(SELECT COUNT(*) FROM (#{ filtered_scope('tags').to_sql }) counted_tags) AS tags,
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
AND deprecated_at IS NOT NULL) AS deprecated_tags,
(SELECT COUNT(*) FROM pairs) AS assignments,
(SELECT COUNT(*) FROM pairs WHERE kind = 'external') AS external_assignments
SQL
row.transform_values!(&:to_i)
row.merge('posts' => available?(@post_start, @at) ? row['posts'] : nil,
'assignments' => available?(@post_start, @at) ? row['assignments'] : nil,
'external_assignments' => available?(@post_start, @at) ?
row['external_assignments'] : nil,
'tags' => available?(@tag_start, @at) ? row['tags'] : nil,
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
end
def available?(first, boundary)
first && (@inclusive ? first <= boundary : first < boundary)
end
def listing
path = quote("$.#{ JSON.generate(@language) }.name")
catalog = <<~SQL
, tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
FROM pairs membership GROUP BY membership.kind, membership.tag_id
), catalog AS (
SELECT 'internal' AS kind, tag_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(names, #{ path })), name) AS name,
deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL
SELECT tc.kind, tc.tag_id, external_version.name, NULL AS deprecated_at
FROM tag_counts tc
LEFT JOIN tag_at internal_version
ON tc.kind = 'internal' AND internal_version.tag_id = tc.tag_id
LEFT JOIN nico_at external_version
ON tc.kind = 'external' AND external_version.tag_id = tc.tag_id
WHERE tc.kind = 'external' OR internal_version.tag_id IS NULL
)
SQL
filters = []
filters << "c.kind = #{ quote(@kind) }" unless @kind == 'all'
filters << "c.tag_id = #{ @tag }" if @tag
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
prefix = "#{ ctes } #{ catalog }"
total = if @metadata
@connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
end
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
'deprecated' => 'c.deprecated_at' }, 'count:desc',
['c.kind ASC', 'c.tag_id ASC'])
rows = @connection.select_all(<<~SQL)
#{ prefix }
SELECT c.kind, c.tag_id, c.name, c.deprecated_at,
COALESCE(n.post_count, 0) AS post_count #{ source } #{ where }
ORDER BY #{ order } LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
SQL
{ count: total, group_count: total, state_rows: rows.map do |row|
{ kind: row['kind'], tag_id: row['tag_id'], name: row['name'],
deprecated: row['deprecated_at']&.iso8601,
count: available?(@post_start, @at) ? row['post_count'].to_i : nil,
at: @at.iso8601(6) }
end }
end
# All record filters apply after selecting the latest recorded version at the point.
def restored_scope(resource)
model = resource == 'posts' ? PostVersion : TagVersion
table = model.table_name
scope = model.where(Arel.sql(latest_condition(table, table, quote(@at))))
.where.not(event_type: 'discard')
if @tag
scope = if resource == 'posts'
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
elsif @kind == 'internal'
scope.where(tag_id: @tag)
else
scope.none
end
end
scope
end
def filtered_scope(resource)
params = @params.slice('from', 'before', 'user_id', 'resource_id')
.merge('mode' => 'details', 'latest' => '0', 'sort' => '')
CountsQuery.new(restored_scope(resource), resource, params,
latest_expression: '1').relation
end
def record_query(mode)
params = @params.slice('locale', 'sort', 'page', 'limit')
.merge('mode' => mode, 'latest' => '0', 'axis' => @axis == 'tags' ? 'month' : @axis,
'page' => @page.to_s, 'limit' => @limit.to_s)
CountsQuery.new(filtered_scope(@resource), @resource, params, latest_expression: '1')
end
def summary
return listing if @axis == 'tags'
query = record_query('summary')
{ count: @metadata ? query.count : nil }.merge(query.summary(include_count: @metadata))
end
def details
query = record_query('details')
{ count: @metadata ? query.count : nil,
versions: CountsVersionRepr.many(query.details, resource: @resource,
language_code: @language, at: @at,
inclusive: @inclusive) }
end
def next_boundary(time)
case @grain
when 'day' then time.beginning_of_day + 1.day
when 'month' then time.beginning_of_month.next_month
else time.beginning_of_year.next_year
end
end
def periods
@before = timestamp('before') || @now.beginning_of_month
@from = timestamp('from') || @before.prev_year
raise ArgumentError if @from >= @before || @before > @now
rows = []
boundary = next_boundary(@from)
while boundary <= @before
raise ArgumentError if rows.length >= MAX_PERIODS
format = { 'day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y' }.fetch(@grain)
rows << [boundary, (boundary - 1.second).strftime(format)]
boundary = next_boundary(boundary)
end
rows
end
def trend
points = periods
metadata = { count: points.length, group_count: points.length, state_rows: [],
from: @from.iso8601, before: @before.iso8601, grain: @grain }
return metadata if points.empty?
values = points.map do |boundary, period|
"SELECT CAST(#{ quote(boundary) } AS DATETIME(6)) AS boundary, " +
"#{ quote(period) } AS period"
end.join(' UNION ALL ')
order = order_sql({ 'period' => 'boundary', 'count' => 'post_count' },
'period:asc', ['boundary ASC'])
known = @post_start ? "periods.boundary > #{ quote(@post_start) }" : 'FALSE'
rows = @connection.select_all(<<~SQL)
WITH periods AS (#{ values }), series AS (
SELECT periods.boundary, periods.period,
CASE WHEN #{ known } THEN COUNT(DISTINCT p.post_id) ELSE NULL END AS post_count
FROM periods
LEFT JOIN post_versions p ON p.event_type <> 'discard'
AND #{ membership('p') }
AND #{ latest_condition('post_versions', 'p', 'periods.boundary') }
AND #{ tag_visible('periods.boundary') }
GROUP BY periods.boundary, periods.period
)
SELECT * FROM series ORDER BY #{ order }
LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
SQL
metadata.merge(state_rows: rows.map do |row|
{ kind: @kind, tag_id: @tag, name: nil, period: row['period'],
at: row['boundary'].utc.iso8601(6), count: row['post_count']&.to_i }
end)
end
def order_sql(expressions, default, final)
raw = @params.fetch('sort', default)
sorts = raw.empty? ? [] : raw.split(',', -1).map { _1.split(':', -1) }
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
raise ArgumentError if sorts.length > expressions.length
order = sorts.flat_map do |key, direction, *extra|
if extra.any? || !expressions.key?(key) || !['asc', 'desc'].include?(direction)
raise ArgumentError
end
expression = expressions.fetch(key)
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
(order + final).join(', ')
end
end
+6
ファイルの表示
@@ -1,4 +1,10 @@
Rails.application.routes.draw do
get 'counts/export', to: 'counts_exports#index'
post 'counts/export', to: 'counts_exports#index'
get 'counts/state', to: 'counts_states#index'
get 'counts/posts', to: 'counts_posts#index'
get 'counts/tags', to: 'counts_tags#index'
resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource
+96
ファイルの表示
@@ -0,0 +1,96 @@
require 'rails_helper'
RSpec.describe 'Counts API', type: :request do
let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
before do
snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag',
category: 'general', sections: [] }
[1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!(post:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_by_user: number == 3 ? nil : actor, created_at: time)
end
end
['posts', 'tags'].each do |resource|
context resource do
let(:path) { "/counts/#{ resource }" }
let(:id) { resource == 'posts' ? post.id : tag.id }
let(:filters) { { resource_id: id, user_id: actor.id,
from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } }
it 'uses the same AND filters for SQL totals, groups and details' do
get path, params: filters.merge(axis: 'event_type')
expect(response).to have_http_status(:ok)
summary = response.parsed_body
expect(summary['count']).to eq(2)
expect(summary['groups'].sum { _1['count'] }).to eq(2)
expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update'])
get path, params: filters.merge(mode: 'details')
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
end
it 'paginates details and groups independently from totals' do
get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 }
expect(response.parsed_body['count']).to eq(3)
expect(response.parsed_body['versions'].sole['version_no']).to eq(2)
get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 }
expect(response.parsed_body['group_count']).to eq(2)
expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end
it 'groups null actors and supports their drill-down filter' do
get path, params: { resource_id: id, axis: 'user' }
expect(response.parsed_body['groups']).to include(
{ 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
expect(response.parsed_body['count']).to eq(1)
end
it 'uses an inclusive start and exclusive end' do
get path, params: { resource_id: id,
from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' }
expect(response.parsed_body['count']).to eq(1)
end
it 'rejects unsupported or malformed query values' do
[
{ axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' },
{ event_type: 'invalid' }, { columns: 'password' }, { order: 'id' },
{ from: '2024-02-30T00:00Z' }, { from: '2024-01-01' },
{ user_id: ['1'] }, { mode: 'invalid' }].each do |invalid|
get path, params: invalid
expect(response).to have_http_status(:bad_request)
end
end
it 'retains the existing banned-user boundary' do
actor.update!(banned_at: Time.current)
sign_in_as(actor)
get path
expect(response).to have_http_status(:forbidden)
end
end
end
it 'matches removed tags from the previous snapshot, not current post_tags' do
get '/counts/posts', params: { tag: tag.id, mode: 'details' }
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed')
end
end
+17 -4
ファイルの表示
@@ -33,9 +33,9 @@ npm run lint
If either command cannot be run or fails, report the exact command and failure.
Do not create, modify, or run tests unless the user explicitly asks for test
work. When the user asks for tests, keep working and rerun them until they
pass or the remaining failure is clearly blocked.
明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コード及び既存のテスト差分にも触れない。
ビルド・lint・静的確認を使い、未確認事項はそのまま報告する。
## TypeScript
@@ -128,6 +128,19 @@ pass or the remaining failure is clearly blocked.
## Tailwind and UI
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
@@ -144,7 +157,7 @@ pass or the remaining failure is clearly blocked.
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
short localised labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
+89 -1
ファイルの表示
@@ -62,6 +62,15 @@ const responseFor = url => {
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return []
if (path === '/counts/posts' || path === '/counts/tags') {
const resource = path.endsWith ('posts') ? 'posts' : 'tags'
return { count: 1, page: 1, limit: 20, group_count: 1,
groups: [{ key: url.searchParams.get ('axis') === 'user' ? 1 : '2024-01',
name: null, count: 1 }],
versions: resource === 'posts'
? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }]
: [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] }
}
if (path === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] }
if (path === '/gekanator/questions') return { questions: [] }
@@ -300,6 +309,25 @@ after (async () => {
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
})
test ('counts SSR renders history data and retains URL query state', async () => {
for (const locale of ['ja', 'ain-Kana']) {
for (const resource of ['posts', 'tags']) {
const column = resource === 'posts' ? 'title' : 'name'
const query = `mode=details&columns=${column}&user_id=4`
const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL)
assert.equal (result.status, 200)
assert.match (result.html, new RegExp (`SSR count ${locale}`))
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`))
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.html, /aria-expanded="false"/)
if (locale === 'ain-Kana')
assert.match (result.html, /lang="ja"[^>]*>集計実行/)
assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`)
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`)))
}
}
})
test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie ()
for (const route of routes) {
@@ -582,7 +610,8 @@ test ('hydrate every route with stored browser preferences', {
}` }, sessionId)
const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) {
for (const route of routes.filter (entry => !entry.handle?.redirectTo
&& (process.env.SSR_COUNTS_ONLY !== '1' || entry.path.startsWith ('counts')))) {
for (const locale of ['ja', 'ain-Kana']) {
await t.test (`hydrate ${locale} ${route.path}`, async () => {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
@@ -608,6 +637,65 @@ test ('hydrate every route with stored browser preferences', {
})
}
}
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId)
for (const resource of ['posts', 'tags']) {
await t.test (`counts layout ${width} ${resource}`, async () => {
await send ('Page.navigate', {
url: `${httpOrigin}/ja/counts/${resource}` }, sessionId)
let ready = false
for (let attempt = 0; attempt < 100; ++attempt) {
await new Promise (resolve => setTimeout (resolve, 75))
try {
ready = await evaluate (`document.querySelector('tbody tr')!=null`)
} catch {}
if (ready) break
}
assert.ok (ready, 'history query results must render')
const layout = await evaluate (`(()=>{
const nav=document.getElementById('counts-targets');
const main=document.querySelector('main').getBoundingClientRect();
return {visible:nav.getClientRects().length>0,
right:nav.getBoundingClientRect().right,left:main.left,width:main.width};
})()`)
assert.ok (width >= 768 ? layout.visible && layout.right <= layout.left
: !layout.visible && layout.width >= width - 16,
'the sidebar sits beside the table on desktop and collapses on mobile')
const toggle = `Array.from(document.querySelectorAll('button'))
.find(button=>button.textContent.includes('集計条件'))`
assert.equal (await evaluate (`(${toggle}).getAttribute('aria-expanded')`), 'false')
await evaluate (`(${toggle}).click()`)
const positions = await evaluate (`(()=>{
const output=document.querySelector('[data-counts-output]').getBoundingClientRect();
const filters=document.querySelector('[data-counts-filters]').getBoundingClientRect();
return {left:output.left,right:filters.left,top:output.top,bottom:filters.top};
})()`)
assert.ok (width >= 768 ? positions.left < positions.right
: positions.top < positions.bottom, 'output precedes filters in the layout')
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'))
if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') {
const { writeFile } = await import ('node:fs/promises')
const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId)
await writeFile (`/tmp/counts-${width}-conditions.png`,
Buffer.from (shot.data, 'base64'))
}
await evaluate (`(${toggle}).click()`)
for (const dark of [false, true]) {
await evaluate (`document.documentElement.classList.toggle('dark',${dark})`)
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'),
'counts controls must fit the viewport in both themes')
if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') {
const { writeFile } = await import ('node:fs/promises')
const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId)
await writeFile (`/tmp/counts-${width}-${dark ? 'dark' : 'light'}.png`,
Buffer.from (shot.data, 'base64'))
}
}
})
}
}
if (process.env.SSR_COUNTS_ONLY === '1') return
probeActive = true
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
+8
ファイルの表示
@@ -77,6 +77,8 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
@@ -194,6 +196,12 @@ export const pageRoutes = (
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsPage/>,
handle: { preload: CountsPage.preload } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
+88
ファイルの表示
@@ -0,0 +1,88 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useUITranslation } from '@/lib/useUITranslation'
export type CountsMenuAction = 'copy' | 'copyHeaders' | 'row' | 'column' | 'all'
const CountsCellMenu = (
{ x, y, act, close }: {
x: number
y: number
act: (action: CountsMenuAction) => void
close: () => void },
) => {
const root = useRef<HTMLDivElement> (null)
const { label, message } = useUITranslation ('counts')
useLayoutEffect (() => {
const menu = root.current
if (menu == null) return
const rect = menu.getBoundingClientRect ()
menu.style.left = `${Math.max (4, Math.min (x, window.innerWidth - rect.width - 4))}px`
menu.style.top = `${Math.max (4, Math.min (y, window.innerHeight - rect.height - 4))}px`
menu.querySelector ('button')?.focus ({ preventScroll: true })
}, [x, y])
useEffect (() => {
const outside = (event: Event) => {
if (!root.current?.contains (event.target as Node)) close ()
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape')
{
event.preventDefault ()
event.stopPropagation ()
close ()
}
}
document.addEventListener ('pointerdown', outside, true)
document.addEventListener ('keydown', escape, true)
window.addEventListener ('resize', close)
document.addEventListener ('scroll', outside, true)
return () => {
document.removeEventListener ('pointerdown', outside, true)
document.removeEventListener ('keydown', escape, true)
window.removeEventListener ('resize', close)
document.removeEventListener ('scroll', outside, true)
}
}, [close])
const actions: CountsMenuAction[] = ['copy', 'copyHeaders', 'row', 'column', 'all']
return createPortal (
<div ref={root} role="menu" aria-label={message ('selection.table').text}
className="fixed z-50 min-w-40 rounded border border-slate-400 bg-white
py-1 text-sm text-slate-900 shadow-lg dark:border-slate-600
dark:bg-slate-800 dark:text-slate-100"
style={{ left: x, top: y }} onContextMenu={event => event.preventDefault ()}
onPointerDown={event => event.stopPropagation ()}
onKeyDown={event => {
event.stopPropagation ()
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase () === 'c')
{
event.preventDefault ()
act ('copy')
return
}
const buttons = Array.from (root.current?.querySelectorAll ('button') ?? [])
const current = buttons.indexOf (document.activeElement as HTMLButtonElement)
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes (event.key))
{
event.preventDefault ()
const next = event.key === 'Home' ? 0 : (event.key === 'End' ? buttons.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length)
% buttons.length)
buttons[next]?.focus ()
}
if (event.key === 'Tab') close ()
}}>
{actions.map (action => (
<button key={action} type="button" role="menuitem"
className="block w-full px-3 py-1 text-left hover:bg-slate-200
hover:text-slate-900 focus:bg-slate-200 focus:text-slate-900
dark:hover:bg-slate-700 dark:hover:text-slate-100
dark:focus:bg-slate-700 dark:focus:text-slate-100"
onClick={() => act (action)}>{label (`selection.${action}`)}</button>))}
</div>, document.body)
}
export default CountsCellMenu
+213
ファイルの表示
@@ -0,0 +1,213 @@
import { useEffect, useId, useState } from 'react'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
import { countsSummaryFields } from '@/lib/countsState'
import { fetchTagByName } from '@/lib/tags'
import { inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts'
const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
resource: CountsResource
applied: URLSearchParams
open: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
reorder: (value: string) => void
actions?: ReactNode },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const id = useId ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const order = applied.get ('order')
const nestedJSON = applied.get ('nested_json')
useEffect (() => {
setDraft (previous => {
const next = new URLSearchParams (previous)
for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON }))
{
if (value == null) next.delete (key)
else next.set (key, value)
}
return next
})
}, [order, nestedJSON])
const [error, setError] = useState (false)
const [pending, setPending] = useState (false)
const mode = draft.get ('mode') ?? 'summary'
const state = isCountsState (draft)
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
const update = (key: string, value: string) => {
setDraft (previous => {
const next = new URLSearchParams (previous)
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
if (key === 'axis' || key === 'basis') next.delete ('summary_columns')
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags')
next.set ('axis', 'month')
if (value || ['columns', 'children', 'summary_columns'].includes (key))
next.set (key, value)
else next.delete (key)
return next
})
}
const submit = async () => {
setError (false)
setPending (true)
try
{
const next = new URLSearchParams (draft)
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
{
const value = next.get (key)?.trim () ?? ''
if (value && !/^[1-9][0-9]*$/.test (value))
{
if (state && next.get ('kind') === 'external') throw new Error ()
const tag = await fetchTagByName (value, locale)
if (tag == null)
throw new Error ()
next.set (key, String (tag.id))
}
}
next.delete ('page')
execute (normaliseCountsParams (resource, next))
}
catch
{
setError (true)
}
finally
{
setPending (false)
}
}
return (
<form className="shrink-0" onSubmit={event => {
event.preventDefault ()
void submit ()
}}>
<div className="flex flex-wrap items-center gap-2 border-b pb-2 dark:border-slate-700">
<Button type="submit" size="sm" disabled={pending}>{label ('run')}</Button>
<Button type="button" size="sm" variant="outline" aria-expanded={open}
aria-controls={id} onClick={() => setOpen (!open)}>
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span>
</Button>
<select className={inputClass (false, 'w-auto py-1')}
aria-label={message ('mode').text} lang={message ('mode').language}
value={mode} onChange={event => update ('mode', event.target.value)}>
{['summary', 'details'].map (value => (
<option key={value} value={value} lang={message (value).language}>
{message (value).text}</option>))}
</select>
{actions}
</div>
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
<div id={id} hidden={!open}
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2
dark:border-slate-700 md:max-h-[45dvh]">
<div className="grid gap-4 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
<legend className="mb-2 font-semibold">{label ('output')}</legend>
<label className="mb-2 flex items-center gap-1">
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
onChange={event => {
update ('nested_json', event.target.checked ? '1' : '0')
displayJSON (event.target.checked)
}}/>
{label ('nested.asJSON')}
</label>
<FormField label={label ('measure')}>
{() => <select value={state ? 'state' : 'history'}
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>)
}
export default CountsControls
+159
ファイルの表示
@@ -0,0 +1,159 @@
import { DndContext, MouseSensor, TouchSensor, closestCenter,
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountsResource } from '@/lib/counts'
type BranchProps = {
field: CountField
path: string
enabled: boolean
selected: Set<string>
params: URLSearchParams
select: (path: string, checked: boolean) => void
update: (key: string, value: string) => void
move: (from: string, to: string) => void
previous?: string
next?: string }
const FieldBranch = (
{ field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps,
) => {
const { label, message } = useUITranslation ('counts')
const fixed = path === 'thumbnail'
const drag = useDraggable ({ id: field.key, disabled: fixed })
const drop = useDroppable ({ id: field.key, disabled: fixed })
const checked = selected.has (path)
const control = <label className="inline-flex items-center gap-1">
<input type="checkbox" checked={checked} disabled={!enabled}
onClick={event => event.stopPropagation ()}
onChange={event => select (path, event.target.checked)}/>
{label (field.label)}
</label>
return (
<div ref={drop.setNodeRef} className={cn ('max-w-full rounded border border-slate-200 px-1',
'dark:border-slate-700',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
style={drag.transform == null ? undefined : {
position: 'relative', zIndex: 1,
transform: `translate3d(${drag.transform.x}px, ${drag.transform.y}px, 0)` }}>
{fixed ? <span className="w-6 shrink-0"/> : (
<button type="button" ref={drag.setActivatorNodeRef}
className="mt-1 w-6 shrink-0 cursor-grab touch-none active:cursor-grabbing"
{...drag.attributes} {...drag.listeners}
aria-keyshortcuts="ArrowUp ArrowDown"
aria-label={message ('reorder', { column: message (field.label).text }).text}
lang={message ('reorder').language}
onClick={event => event.stopPropagation ()}
onKeyDown={event => {
if (!['ArrowUp', 'ArrowDown'].includes (event.key)) return
event.preventDefault ()
event.stopPropagation ()
const target = event.key === 'ArrowUp' ? previous : next
if (target != null) move (field.key, target)
}}><span aria-hidden>⠿</span></button>)}
{field.children ? (
<details className="my-1 min-w-0 flex-1">
<summary className="cursor-pointer">{control}</summary>
<div className="ml-2 border-l pl-1 dark:border-slate-700">
<FieldGroup fields={field.children} path={path} params={params}
enabled={enabled && checked} selected={selected} select={select}
update={update}/>
</div>
</details>) : <div className="my-1">{control}</div>}
</div>
</div>)
}
const FieldGroup = (
{ fields, path, params, enabled, selected, select, update }: {
fields: CountField[]
path: string
params: URLSearchParams
enabled: boolean
selected: Set<string>
select: BranchProps['select']
update: BranchProps['update'] },
) => {
const { message } = useUITranslation ('counts')
const ordered = orderedCountFields (fields, params, path)
const movable = ordered.filter (field => path !== '' || field.key !== 'thumbnail')
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
const move = (from: string, to: string) =>
update ('order', reorderCountFields (fields, params, path, from, to))
return (
<DndContext sensors={sensors} collisionDetection={closestCenter}
accessibility={{ announcements: {
onDragStart: () => undefined, onDragOver: () => undefined,
onDragMove: () => undefined,
onDragEnd: () => undefined, onDragCancel: () => undefined },
screenReaderInstructions: { draggable: message ('output').text } }}
onDragEnd={({ active, over }) => {
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
}}>
<div className="flex flex-wrap items-start gap-1">
{ordered.map (field => {
const position = movable.findIndex (item => item.key === field.key)
return <FieldBranch key={field.key} field={field}
path={path ? `${path}.${field.key}` : field.key}
enabled={enabled} selected={selected} params={params}
select={select} update={update} move={move}
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
})}
</div>
</DndContext>)
}
const CountsFieldTree = (
{ resource, params, update, fields: suppliedFields,
selectionKey = 'columns', parent = '' }: {
resource: CountsResource
fields?: CountField[]
selectionKey?: string
parent?: string
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label } = useUITranslation ('counts')
const fields = suppliedFields ?? fieldsForCounts (resource)
const roots = suppliedFields == null ? selectedCountColumns (resource, params)
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
const selected = selectedCountChildren (params)
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
for (const root of roots) selected.add (rootPath (root))
const select = (path: string, checked: boolean) => {
const root = fields.find (field => rootPath (field.key) === path)
if (root != null)
{
const next = new Set<string> (roots)
if (checked) next.add (root.key)
else next.delete (root.key)
update (selectionKey, fields.filter (field => next.has (field.key))
.map (field => field.key).join (','))
return
}
if (checked) selected.add (path)
else selected.delete (path)
update ('children', countChildPaths (fields).filter (
key => key.includes ('.') && selected.has (key)).join (','))
}
return (
<>
<FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
select={select} update={update}/>
{suppliedFields == null && <p className="mt-2 text-xs">{label ('nested.selection')}</p>}
</>)
}
export default CountsFieldTree
+107
ファイルの表示
@@ -0,0 +1,107 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import { changeCountSort, readCountSort, selectedCountChildren,
writeCountSort, orderedCountFields } from '@/lib/counts'
import { countsLogicalName, countsNestedExport,
countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountRecord, CountsResource } from '@/lib/counts'
const CountsNestedTable = (
{ rows, fields, path, resource, title, params, update }: {
rows: CountRecord[]
fields: CountField[]
path: string
resource: CountsResource
title: { text: string; language: string }
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { message } = useUITranslation ('counts')
const locale = useLocale ()
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
const allSorts = readCountSort (params.get ('child_sort'))
const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`))
.map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
const ordered = countsNestedOrder (rows, path, params, locale)
const scalar = (row: CountRecord, field: string) => {
const value = row[field]
if (value == null) return null
if (field === 'id')
{
if (row.kind === 'external') return String (value)
const target = path === 'parents' ? resource : 'tags'
return <PrefetchLink to={`/${target}/${encodeURIComponent (String (value))}`}>
{String (value)}</PrefetchLink>
}
const display = countsLogicalName (row, field, locale)
if (typeof value === 'boolean')
return <span aria-label={display.text} lang={display.language}>{value ? '✓' : '✗'}</span>
if (display.language != null)
return <LocalisedText language={display.language}>{display.text}</LocalisedText>
if (field === 'name' && typeof row.languageCode === 'string')
return <LocalisedText language={row.languageCode}>{String (value)}</LocalisedText>
return display.text
}
return (
<div className="counts-nested w-full min-w-0 max-w-full">
{params.get ('nested_json') === '1' ? (
<pre data-counts-scroll
aria-label={title.text} lang={title.language}
className="max-h-72 w-full overflow-auto whitespace-pre-wrap
break-words text-xs">
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
</pre>) : (columns.length > 0 && rows.length > 0 && (
<div data-counts-scroll
className="relative isolate max-h-72 w-full min-w-0 overflow-auto">
<CountsSelectableTable family={path} columns={columns.map (field => field.key)}
identity={`${path}:${params}`} name={title}
className="text-xs">
<thead className="sticky top-0 z-10 bg-background text-foreground
dark:bg-background dark:text-foreground"><tr>
{columns.map (field => (
<CountsSortHeader key={field.key} field={field.key}
label={message (field.label)}
sorts={sorts} structured={field.children != null}
change={(key, additive) => {
const next = changeCountSort (sorts, key, additive)
.map (sort => ({ ...sort, key: `${path}/${sort.key}` }))
update ('child_sort', writeCountSort ([
...allSorts.filter (sort => !sort.key.startsWith (`${path}/`)),
...next]))
}}/>))}
</tr></thead>
<tbody>{ordered.map ((row, index) => (
<tr key={index} className="border-t dark:border-slate-700">
{columns.map (field => (
<td key={field.key}
className="overflow-hidden break-words border-t px-2 py-1
align-top dark:border-slate-700"
data-counts-copy={
field.children
? countsNestedText (
row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: countsLogicalName (row, field.key, locale).text}>
{field.children
? <CountsNestedTable rows={row[field.key] as CountRecord[] ?? []}
fields={field.children} path={`${path}.${field.key}`}
resource={resource}
title={message (field.label)} params={params} update={update}/>
: scalar (row, field.key)}
</td>))}
</tr>))}</tbody>
</CountsSelectableTable>
</div>))}
</div>)
}
export default CountsNestedTable
+93
ファイルの表示
@@ -0,0 +1,93 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { CountsTableScope, MAX_COUNT_WIDTH, MIN_COUNT_WIDTH,
useCountsTableLayout } from '@/lib/countsTableLayout'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
const CountsResizeHandle = ({ field, label }: {
field: string; label: { text: string; language: string } }) => {
const family = useContext (CountsTableScope)
const layout = useCountsTableLayout ()
const { message } = useUITranslation ('counts')
const [active, setActive] = useState (false)
const press = useRef<{ x: number; y: number; width: number; armed: boolean
timer?: ReturnType<typeof setTimeout> } | null> (null)
const saved = useRef<{ cursor: string; userSelect: string } | null> (null)
const restore = () => {
if (saved.current == null) return
document.body.style.cursor = saved.current.cursor
document.body.style.userSelect = saved.current.userSelect
saved.current = null
}
const finish = () => {
if (press.current?.timer != null) clearTimeout (press.current.timer)
press.current = null
setActive (false)
restore ()
}
const arm = () => {
if (press.current == null) return
press.current.armed = true
saved.current = { cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect }
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
setActive (true)
}
useEffect (() => () => {
if (press.current?.timer != null) clearTimeout (press.current.timer)
if (saved.current != null)
{
document.body.style.cursor = saved.current.cursor
document.body.style.userSelect = saved.current.userSelect
}
}, [])
const name = message ('resize.column', { column: label.text })
return (
<span role="separator" tabIndex={0} aria-orientation="vertical"
aria-label={name.text} lang={name.language}
aria-valuenow={layout.width (family, field)} aria-valuemin={MIN_COUNT_WIDTH}
aria-valuemax={MAX_COUNT_WIDTH} title={name.text}
data-counts-resize
className={cn ('absolute inset-y-0 right-0 z-30 w-3 cursor-col-resize touch-none',
'select-none outline-offset-[-2px] focus-visible:outline',
'before:absolute before:inset-y-1 before:left-1/2 before:border-l',
'before:border-slate-300 dark:before:border-slate-600',
'hover:before:border-blue-500 dark:hover:before:border-blue-400',
active && 'before:border-blue-500 dark:before:border-blue-400')}
onPointerDown={event => {
event.stopPropagation ()
if (!event.isPrimary || event.button !== 0) return
event.preventDefault ()
finish ()
event.currentTarget.setPointerCapture (event.pointerId)
press.current = { x: event.clientX, y: event.clientY,
width: layout.width (family, field), armed: false }
if (event.pointerType === 'mouse') arm ()
else press.current.timer = setTimeout (arm, 400)
}}
onPointerMove={event => {
event.stopPropagation ()
const state = press.current
if (state == null) return
if (state.armed)
layout.resize (family, field, state.width + event.clientX - state.x)
else if (Math.hypot (event.clientX - state.x, event.clientY - state.y) > 8)
finish ()
}}
onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={finish}
onClick={event => { event.preventDefault (); event.stopPropagation () }}
onContextMenu={event => event.preventDefault ()}
onKeyDown={event => {
event.stopPropagation ()
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault ()
layout.resize (family, field, layout.width (family, field)
+ (event.key === 'ArrowLeft' ? -10 : 10))
}}/>)
}
export default CountsResizeHandle
+128
ファイルの表示
@@ -0,0 +1,128 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsNestedTable from '@/components/counts/CountsNestedTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
import useDialogue from '@/lib/dialogues/useDialogue'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountColumn, CountRecord, CountsResource,
CountsResult, CountsVersion } from '@/lib/counts'
const CountsResults = (
{ resource, data, params, pending, update }: {
resource: CountsResource
data: CountsResult
params: URLSearchParams
pending: boolean
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const dialogue = useDialogue ()
const columns = selectedCountColumns (resource, params)
const fields = fieldsForCounts (resource)
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
const sort = (key: string, additive: boolean) =>
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
const cell = (row: CountsVersion, column: CountColumn) => {
const field = fields.find (field => field.key === column)!
const value = countsCellText (row, column, locale)
if (field.children != null)
{
return <CountsNestedTable rows={row[column as keyof CountsVersion] as CountRecord[] ?? []}
fields={field.children} path={column} resource={resource}
title={message (field.label)} params={params} update={update}/>
}
switch (column)
{
case 'resource_id':
return <PrefetchLink to={`/${resource}/${encodeURIComponent (row.resourceId)}`}>
{row.resourceId}</PrefetchLink>
case 'thumbnail':
{
const url = countsImageURL (row.thumbnailBase)
return url == null ? null : (
<button type="button" className="block" aria-label={message (
'shared:labels.thumbnail').text}
onClick={event => {
const trigger = event.currentTarget
void dialogue.form ({
title: label ('shared:labels.thumbnail'),
cancelText: label ('shared:dialogue.close'),
size: 'viewport',
body: () => <img src={url} alt={message ('shared:labels.thumbnail').text}
referrerPolicy="no-referrer" className="h-full w-full object-contain"/>
}).then (() => trigger.focus ({ preventScroll: true }))
}}>
<PostThumbnailPreview url={url} className="h-10 w-16"
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/>
</button>)
}
case 'url':
case 'thumbnail_base':
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
{value}</a>
case 'is_latest':
return <LocalisedText language={message ('latestValue').language}>
{value}</LocalisedText>
case 'user':
return row.createdByUser?.name ? value : (
<LocalisedText language={message (
row.createdByUser == null ? 'history:bot' : 'history:anonymous').language}>
{value}</LocalisedText>)
case 'category':
return <LocalisedText language={message (`common:categories.${row.category}`).language}>
{value}</LocalisedText>
default: return value
}
}
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={resource}
columns={columns}
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
(data.versions ?? []).map (row => row.id)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20 bg-background text-foreground
dark:bg-background dark:text-foreground"><tr>
{columns.map (column => column === 'thumbnail'
? <th key={column} data-counts-heading={message (countColumns[column]).text}
className="relative px-2 py-1">
<span className="sr-only">{label (countColumns[column])}</span>
<CountsResizeHandle field={column} label={message (countColumns[column])}/>
</th>
: <CountsSortHeader key={column} field={column}
label={message (fields.find (field => field.key === column)!.label)}
sorts={sorts} change={sort} structured={fields.find (
field => field.key === column)?.children != null}/>)}
</tr></thead>
<tbody>
{(data.versions ?? []).map (row => (
<tr key={row.id} className="border-b dark:border-slate-700">
{columns.map (column => (
<td key={column}
className="overflow-hidden break-words border-b px-2 py-1
align-top dark:border-slate-700"
data-counts-copy={countsCellCopy (
row,
fields.find (field => field.key === column)!, params, locale)}>
{cell (row, column)}</td>))}
</tr>))}
</tbody>
</CountsSelectableTable>)}
{(data.versions?.length ?? 0) === 0
&& <p className="p-2">{label ('empty')}</p>}
</div>)
}
export default CountsResults
+306
ファイルの表示
@@ -0,0 +1,306 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import CountsCellMenu from '@/components/counts/CountsCellMenu'
import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard'
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ClipboardEvent, ReactNode } from 'react'
import type { CountsMenuAction } from '@/components/counts/CountsCellMenu'
type Point = { row: number; column: number }
type Selection = { identity: string; anchor: Point; end: Point }
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
const cellAt = (table: HTMLTableElement, point: Point) =>
table.tBodies[0]?.rows[point.row]?.cells[point.column]
const CountsSelectableTable = (
{ family, columns, identity, children, className, name }: {
family: string
columns: string[]
identity: string
children: ReactNode
className?: string
name?: { text: string; language: string } },
) => {
const id = useId ()
const root = useRef<HTMLTableElement> (null)
const dragging = useRef (false)
const touchArmed = useRef (false)
const touchPress = useRef<{ x: number; y: number
timer: ReturnType<typeof setTimeout> } | null> (null)
const [menu, setMenu] = useState<{ x: number; y: number; point: Point
identity: string } | null> (null)
const suppressClick = useRef (false)
const [stored, setStored] = useState<Selection | null> (null)
const selection = stored?.identity === identity ? stored : null
const layout = useCountsTableLayout ()
const { label, message } = useUITranslation ('counts')
const bounds = useMemo (() => selection == null ? null : {
top: Math.min (selection.anchor.row, selection.end.row),
bottom: Math.max (selection.anchor.row, selection.end.row),
left: Math.min (selection.anchor.column, selection.end.column),
right: Math.max (selection.anchor.column, selection.end.column) }, [selection])
const finish = () => {
dragging.current = false
touchArmed.current = false
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
touchPress.current = null
}
useEffect (() => {
const table = root.current
const preventPan = (event: TouchEvent) => {
if (touchArmed.current) event.preventDefault ()
}
table?.addEventListener ('touchmove', preventPan, { passive: false })
return () => {
table?.removeEventListener ('touchmove', preventPan)
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
}
}, [])
useLayoutEffect (() => {
const rows = root.current?.tBodies[0]?.rows
if (rows == null) return
for (const [row, tr] of Array.from (rows).entries ())
{
for (const [column, cell] of Array.from (tr.cells).entries ())
{
const selected = bounds != null && row >= bounds.top && row <= bounds.bottom
&& column >= bounds.left && column <= bounds.right
cell.id = `${id}-${row}-${column}`
cell.setAttribute ('role', 'gridcell')
cell.setAttribute ('aria-selected', String (selected))
if (selected) cell.dataset.countsSelected = ''
else delete cell.dataset.countsSelected
}
}
}, [bounds, children, id])
const pointFor = (element: Element | null): Point | null => {
const table = root.current
if (table == null || element == null || !table.contains (element)) return null
let cell = element.closest ('td')
while (cell != null && cell.closest ('table') !== table)
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
column: (cell as HTMLTableCellElement).cellIndex }
}
const closeMenu = useCallback (() => {
setMenu (null)
root.current?.focus ({ preventScroll: true })
}, [])
const openMenu = (point: Point, x: number, y: number) => {
const inside = bounds != null && point.row >= bounds.top && point.row <= bounds.bottom
&& point.column >= bounds.left && point.column <= bounds.right
if (inside) setStored (selection)
else setStored ({ identity, anchor: point, end: point })
setMenu ({ point, x, y, identity })
}
const clipboard = (headers = false) => {
if (bounds == null || root.current == null) return null
const rows: string[][] = []
for (let row = bounds.top; row <= bounds.bottom; ++row)
{
const values: string[] = []
for (let column = bounds.left; column <= bounds.right; ++column)
{
const cell = cellAt (root.current, { row, column })
values.push (cell?.dataset.countsCopy ?? cell?.innerText ?? '')
}
rows.push (values)
}
if (headers)
{
const heading = root.current.tHead?.rows[0]
rows.unshift (Array.from ({ length: bounds.right - bounds.left + 1 }, (entry, index) => {
void entry
const cell = heading?.cells[bounds.left + index]
return cell?.dataset.countsHeading ?? cell?.textContent ?? ''
}))
}
return countsClipboard (rows, headers)
}
const menuAction = (action: CountsMenuAction) => {
if (menu == null || root.current == null) return
if (action === 'copy' || action === 'copyHeaders')
{
const payload = clipboard (action === 'copyHeaders')
if (payload != null) void writeCountsClipboard (payload).catch (error => {
console.error (error)
})
}
else
{
const lastRow = (root.current.tBodies[0]?.rows.length ?? 1) - 1
const lastColumn = columns.length - 1
const anchor = {
row: action === 'row' ? menu.point.row : 0,
column: action === 'column' ? menu.point.column : 0 }
const end = {
row: action === 'row' ? menu.point.row : lastRow,
column: action === 'column' ? menu.point.column : lastColumn }
setStored ({ identity, anchor, end })
}
closeMenu ()
}
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
if (document.activeElement !== root.current) return
const payload = clipboard ()
if (payload == null) return
event.preventDefault ()
event.stopPropagation ()
event.clipboardData.setData ('text/plain', payload.text)
try
{
event.clipboardData.setData ('text/html', payload.html)
}
catch
{
// Plain TSV remains available when the browser disallows HTML clipboard data.
}
}
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
return (
<CountsTableScope.Provider value={family}>
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
aria-label={name?.text ?? message ('selection.table').text}
aria-describedby={`${id}-hint`}
aria-activedescendant={selection == null ? undefined
: `${id}-${selection.end.row}-${selection.end.column}`}
style={{ tableLayout: 'fixed', width: totalWidth }}
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
'dark:focus-visible:outline-blue-400',
'[&>tbody>tr>td[data-counts-selected]]:bg-blue-100',
'[&>tbody>tr>td[data-counts-selected]]:text-slate-900',
'dark:[&>tbody>tr>td[data-counts-selected]]:bg-blue-900',
'dark:[&>tbody>tr>td[data-counts-selected]]:text-slate-100',
'[&>tbody>tr>td[data-counts-selected]]:outline',
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
onCopy={copy}
onClickCapture={event => {
if ((event.target as Element).closest ('table') !== event.currentTarget
|| !suppressClick.current) return
event.preventDefault ()
event.stopPropagation ()
suppressClick.current = false
}}
onPointerDown={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
suppressClick.current = false
if (element.closest (interactive) != null || event.button !== 0) return
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
{
const rect = element.getBoundingClientRect ()
if (event.clientX >= rect.left + element.clientLeft + element.clientWidth
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
}
const point = pointFor (element)
if (point == null) return
event.stopPropagation ()
setMenu (null)
event.currentTarget.focus ({ preventScroll: true })
setStored ({ identity, anchor: event.shiftKey && selection != null
? selection.anchor : point, end: point })
finish ()
if (event.pointerType === 'mouse')
{
event.preventDefault ()
dragging.current = true
event.currentTarget.setPointerCapture (event.pointerId)
}
else
{
const table = event.currentTarget
const pointerId = event.pointerId
touchPress.current = { x: event.clientX, y: event.clientY,
timer: setTimeout (() => {
suppressClick.current = true
openMenu (point, event.clientX, event.clientY)
dragging.current = true
touchArmed.current = true
table.setPointerCapture (pointerId)
}, 450) }
}
}}
onPointerMove={event => {
if (touchPress.current != null && !touchArmed.current && Math.hypot (
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
finish ()
if (!dragging.current || event.buttons === 0) return
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
if (point == null) return
if (touchArmed.current && touchPress.current != null
&& Math.hypot (event.clientX - touchPress.current.x,
event.clientY - touchPress.current.y) <= 8) return
setMenu (null)
event.stopPropagation ()
setStored (previous => previous?.identity !== identity ? previous : {
...previous, end: point })
}}
onPointerUp={finish} onPointerCancel={finish}
onLostPointerCapture={event => {
if (event.target === event.currentTarget) finish ()
}}
onContextMenu={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
const point = pointFor (element)
if (point == null || element.closest ('[data-counts-resize]') != null) return
event.preventDefault ()
event.stopPropagation ()
openMenu (point, event.clientX, event.clientY)
}}
onKeyDown={event => {
if (event.target !== event.currentTarget) return
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))
{
const point = selection?.end ?? { row: 0, column: 0 }
const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect ()
if (rect != null)
{
event.preventDefault ()
openMenu (point, rect.left, rect.bottom)
}
return
}
if (event.key === 'Escape')
{
setStored (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
event.preventDefault ()
const previous = selection?.end ?? { row: 0, column: 0 }
const key = selection == null ? '' : event.key
const point = {
row: Math.max (0, Math.min (rows.length - 1, previous.row
+ (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))),
column: Math.max (0, Math.min (columns.length - 1, previous.column
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
setStored ({ identity, anchor: event.shiftKey && selection != null
? selection.anchor : point, end: point })
cellAt (event.currentTarget, point)?.scrollIntoView ({
block: 'nearest', inline: 'nearest' })
}}>
<caption id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</caption>
<colgroup>{columns.map (key => (
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
{children}
</table>
{menu?.identity === identity && <CountsCellMenu x={menu.x} y={menu.y}
act={menuAction} close={closeMenu}/>}
</CountsTableScope.Provider>)
}
export default CountsSelectableTable
+97
ファイルの表示
@@ -0,0 +1,97 @@
import { useEffect, useRef } from 'react'
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountSort } from '@/lib/counts'
const CountsSortHeader = (
{ field, label, sorts, change, structured = false }: {
field: string
label: { text: string; language: string }
sorts: CountSort[]
change: (key: string, additive: boolean) => void
structured?: boolean },
) => {
const { message } = useUITranslation ('counts')
const press = useRef<{ x: number; y: number
timer: ReturnType<typeof setTimeout> } | null> (null)
const suppressClick = useRef (false)
const cancel = () => {
if (press.current != null) clearTimeout (press.current.timer)
press.current = null
}
useEffect (() => () => {
if (press.current != null) clearTimeout (press.current.timer)
}, [])
const priority = sorts.findIndex (sort => sort.key === field)
const direction = sorts[priority]?.direction
const state = priority < 0 ? 'none' : (direction === 'asc' ? 'ascending' : 'descending')
const title = message (structured ? 'sort.structured' : 'sort.action')
const aria = message (priority < 0 ? 'sort.unsortedLabel'
: (sorts.length === 1 ? 'sort.singleLabel' : 'sort.label'), {
column: label.text, priority: priority + 1,
direction: message (`sort.${state}`).text })
return (
<th data-counts-heading={label.text}
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
<Button type="button" variant="ghost" size="sm"
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
lang={aria.language} aria-label={aria.text} title={title.text}
onPointerDown={event => {
if (!event.isPrimary)
{
cancel ()
return
}
suppressClick.current = false
if (event.pointerType !== 'touch' && event.pointerType !== 'pen')
return
cancel ()
const x = event.clientX
const y = event.clientY
press.current = { x, y, timer: setTimeout (() => {
press.current = null
suppressClick.current = true
change (field, true)
}, 500) }
}}
onPointerMove={event => {
if (press.current != null && Math.hypot (
event.clientX - press.current.x, event.clientY - press.current.y) > 8)
cancel ()
}}
onPointerUp={cancel} onPointerCancel={cancel} onPointerLeave={cancel}
onContextMenu={event => {
if (press.current != null || suppressClick.current)
event.preventDefault ()
}}
onKeyDown={event => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault ()
suppressClick.current = false
change (field, event.ctrlKey || event.metaKey)
}}
onClick={event => {
if (suppressClick.current)
{
suppressClick.current = false
event.preventDefault ()
return
}
change (field, event.ctrlKey || event.metaKey)
}}>
<span className="truncate" lang={label.language}>{label.text}</span>
{priority >= 0 && <span aria-hidden>
{direction === 'asc' ? '↑' : '↓'}{sorts.length > 1 && priority + 1}</span>}
</Button>
<CountsResizeHandle field={field} label={label}/>
</th>)
}
export default CountsSortHeader
+63
ファイルの表示
@@ -0,0 +1,63 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsSummaryCell, countsSummaryDrill, countsSummaryRows,
selectedCountsSummaryFields } from '@/lib/countsState'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource, CountsResult } from '@/lib/counts'
const CountsSummaryResults = (
{ resource, data, params, pending, update }: {
resource: CountsResource
data: CountsResult
params: URLSearchParams
pending: boolean
update: (key: string, value: string) => void },
) => {
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const fields = selectedCountsSummaryFields (resource, params)
const rows = countsSummaryRows (data)
const sorts = readCountSort (params.get ('sort') ?? (
params.get ('axis') === 'tags' ? 'count:desc' : 'group:asc'))
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{fields.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={`summary_${params.get ('axis')}`}
columns={fields.map (field => field.key)}
identity={`${countsRequestParams (params)}:${fields.map (field => field.key)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20"><tr>
{fields.map (field => (
<CountsSortHeader key={field.key} field={field.key}
label={message (field.label)}
sorts={sorts} change={(key, additive) => update (
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
</tr></thead>
<tbody>{rows.map (row => (
<tr key={String (row.key)}>
{fields.map (field => {
const value = countsSummaryCell (row, field.key, locale)
const content = value.language == null ? value.text : (
<LocalisedText language={value.language}>{value.text}</LocalisedText>)
return <td key={field.key} data-counts-copy={value.text}
className="break-words border-b px-2 py-1 dark:border-slate-700">
{(['count', 'group'].includes (field.key) && row.count != null)
? <PrefetchLink to={countsSummaryDrill (resource, params, row)}>
{content}</PrefetchLink>
: content}
</td>
})}
</tr>))}</tbody>
</CountsSelectableTable>)}
{rows.length === 0 && <p className="p-2">{label ('empty')}</p>}
</div>)
}
export default CountsSummaryResults
+24
ファイルの表示
@@ -0,0 +1,24 @@
import { useState } from 'react'
import { CountsLayoutContext, defaultCountWidths, MAX_COUNT_WIDTH, MIN_COUNT_WIDTH }
from '@/lib/countsTableLayout'
import type { ReactNode } from 'react'
const CountsTableLayout = ({ children }: { children: ReactNode }) => {
const [widths, setWidths] = useState<Record<string, number>> ({ })
const width = (family: string, key: string) =>
widths[`${family}/${key}`] ?? defaultCountWidths[key] ?? 160
const resize = (family: string, key: string, value: number) => {
const next = Math.round (Math.max (MIN_COUNT_WIDTH, Math.min (MAX_COUNT_WIDTH, value)))
setWidths (previous => ({ ...previous, [`${family}/${key}`]: next }))
}
return (
<CountsLayoutContext.Provider value={{ width, resize }}>
{children}
</CountsLayoutContext.Provider>)
}
export default CountsTableLayout
+45
ファイルの表示
@@ -0,0 +1,45 @@
.counts-grid > tbody > tr > td {
@apply h-6;
}
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
@apply bg-white text-slate-900;
}
.counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
@apply bg-slate-50 text-slate-900;
}
.dark .counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
@apply bg-slate-950 text-slate-100;
}
.dark .counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
@apply bg-slate-900 text-slate-100;
}
.counts-grid > thead {
@apply bg-slate-200 text-slate-950;
}
.counts-grid > thead > tr > th {
@apply border-b border-r border-slate-300 bg-slate-200 text-slate-950;
}
.dark .counts-grid > thead,
.dark .counts-grid > thead > tr > th {
@apply border-slate-600 bg-slate-800 text-slate-50;
}
.counts-grid > tbody > tr > td {
@apply border-r border-slate-200 dark:border-slate-700;
-webkit-touch-callout: none;
}
.counts-grid > tbody > tr > td:has(> .counts-nested) {
padding: 0;
}
.counts-nested {
@apply border-l-2 border-slate-300 dark:border-slate-600;
}
+7 -7
ファイルの表示
@@ -8,6 +8,7 @@ import { Dialog,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, ReactNode } from 'react'
@@ -183,13 +184,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogContent
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
? active.options.closeLabel : undefined}
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7'
}
className={cn (active.kind === 'form'
? 'flex max-h-[calc(100dvh-1rem)] flex-col overflow-hidden gap-0'
: 'px-6 pb-6 pt-7', active.kind === 'form' && (
active.options.size === 'viewport'
? 'h-[88dvh] w-[88vw] max-w-none'
: (active.options.size === 'large' ? 'max-w-3xl' : 'max-w-lg')))}
onEscapeKeyDown={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
+1
ファイルの表示
@@ -1,4 +1,5 @@
@import "@fontsource-variable/noto-sans-jp";
@import './components/counts/countsTable.css';
@tailwind base;
@tailwind components;
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, normaliseCountsParams } from '@/lib/counts'
import { createUII18n, translateUIMessage } from '@/lib/i18n'
describe ('counts exports', () => {
it ('resolves every output and CSV header key in both locales', () => {
for (const locale of ['ja', 'ain-Kana'])
{
const instance = createUII18n (locale)
for (const key of Object.values (countColumns))
{
const translated = translateUIMessage (key, 'counts', locale, { }, instance)
expect (translated.text).not.toBe (key)
expect (translated.text).not.toContain ('counts:')
}
const fallback = translateUIMessage ('run', 'counts', locale, { }, instance)
expect (fallback).toEqual ({ text: '集計実行', language: 'ja' })
}
})
it ('makes the monthly default explicit for the existing API', () => {
expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis'))
.toBe ('month')
})
it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
})
it ('keeps resource-specific columns out of the other resource', () => {
expect (columnsForCounts ('posts')).toContain ('added_tags')
expect (columnsForCounts ('posts')).not.toContain ('aliases')
expect (columnsForCounts ('tags')).toContain ('aliases')
expect (columnsForCounts ('tags')).not.toContain ('url')
expect (columnsForCounts ('posts')).not.toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('event_type')
})
})
+358
ファイルの表示
@@ -0,0 +1,358 @@
import { apiGet, apiPost } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
export type CountsResource = 'posts' | 'tags'
export type CountsName = TagNameSnapshot & {
languageCode: string; languageName?: string | null; scriptName?: string | null }
export type CountsTag = {
id: number
kind: 'internal' | 'external'
tagId: number | null
externalTagId: number | null
versionNo: number | null
name: string | null
category: string | null
sections: { beginMs: number; endMs: number | null }[]
names: CountsName[] }
export type CountsVersion = {
id: number
resourceId: number
versionNo: number
isLatest: boolean
createdAt: string
createdByUser: { id: number; name: string | null } | null
title?: string | null
url?: string
thumbnailBase?: string | null
tags?: CountsTag[]
parents: { id: number }[]
videoMs?: number | null
originalCreatedFrom?: string | null
originalCreatedBefore?: string | null
name?: string | null
names?: CountsName[]
category?: string
aliases?: { languageCode: string; name: string; scriptCode: string }[]
deprecated?: string | null }
export type CountsStateRow = {
kind: 'internal' | 'external'
tagId: number
name: string | null
deprecated?: string | null
period?: string
at: string
count: number | null }
export type CountsStateTotals = {
posts: number | null
tags: number | null
assignments: number | null
deprecatedTags: number | null
externalAssignments: number | null }
export type CountsResult = {
count: number
page: number
limit: number
groupCount?: number
groups?: { key: string | number | null; name: string | null; count: number }[]
versions?: CountsVersion[]
at?: string
from?: string
before?: string
grain?: string
totals?: CountsStateTotals
stateRows?: CountsStateRow[]
coverage?: { basis: string; exact: boolean
postHistoryFrom: string | null; tagHistoryFrom: string | null } }
export type CountSort = { key: string; direction: 'asc' | 'desc' }
export type CountField = { key: string; label: string; children?: CountField[] }
export type CountRecord = Record<string, unknown>
export const countColumns = {
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
version_no: 'history:version', is_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',
original_created_at: 'shared:labels.originalCreatedAt',
original_created_from: 'counts:columns.originalFrom',
original_created_before: 'counts:columns.originalBefore',
name: 'history:tagName', names: 'counts:columns.names',
category: 'common:categoryLabel', aliases: 'tags:aliases',
deprecated: 'counts:columns.deprecated', user: 'history:actor',
created_at: 'counts:columns.changedAt' }
export type CountColumn = keyof typeof countColumns
const aliasFields: CountField[] = [
{ key: 'name', label: 'history:name' },
{ key: 'scriptCode', label: 'history:nameChanges.script' }]
const nameFields: CountField[] = [
{ key: 'languageCode', label: 'counts:columns.language' },
{ key: 'scriptCode', label: 'history:nameChanges.script' },
{ key: 'name', label: 'history:name' },
{ key: 'aliases', label: 'tags:aliases', children: aliasFields },
{ key: 'autoGenerated', label: 'history:nameChanges.automatic' }]
const nested: Partial<Record<CountColumn, CountField[]>> = {
tags: [
{ key: 'id', label: 'counts:columns.resourceId' },
{ key: 'versionNo', label: 'history:version' },
{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'name', label: 'history:name' },
{ key: 'category', label: 'common:categoryLabel' },
{ key: 'sections', label: 'counts:columns.sections' },
{ key: 'names', label: 'counts:columns.names', children: nameFields }],
names: nameFields,
aliases: [{ key: 'languageCode', label: 'counts:columns.language' }, ...aliasFields],
parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
...(resource === 'posts' ? ['thumbnail'] as const : []),
'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'original_created_at', 'original_created_from', 'original_created_before'] as const
: ['name', 'names', 'category', 'aliases', 'parents', 'deprecated'] as const),
'user', 'created_at']
export const fieldsForCounts = (resource: CountsResource): CountField[] =>
columnsForCounts (resource).map (key => ({
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents' : countColumns[key],
children: nested[key] }))
export const orderedCountFields = (
fields: CountField[],
params: URLSearchParams,
parent = '',
): CountField[] => {
const prefix = parent ? `${parent}.` : ''
const saved = (params.get ('order') ?? '').split (',')
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
const ordered = keys.flatMap (key => {
const field = fields.find (field => `${prefix}${field.key}` === key)
return field == null ? [] : [field]
})
return parent ? ordered : [
...ordered.filter (field => field.key === 'thumbnail'),
...ordered.filter (field => field.key !== 'thumbnail')]
}
export const reorderCountFields = (
fields: CountField[],
params: URLSearchParams,
parent: string,
from: string,
to: string,
): string => {
const ordered = orderedCountFields (fields, params, parent)
const first = ordered.findIndex (field => field.key === from)
const last = ordered.findIndex (field => field.key === to)
if (first < 0 || last < 0) return params.get ('order') ?? ''
const prefix = parent ? `${parent}.` : ''
const current = ordered.map (field => `${prefix}${field.key}`)
const next = [...current]
next.splice (last, 0, ...next.splice (first, 1))
return [...(params.get ('order') ?? '').split (',').filter (
key => key && !current.includes (key)), ...next].join (',')
}
export const selectedCountColumns = (
resource: CountsResource,
params: URLSearchParams,
): CountColumn[] => {
const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
columnsForCounts (resource).filter (key => ![
'thumbnail', 'original_created_from', 'original_created_before']
.includes (key))
return orderedCountFields (fieldsForCounts (resource), params)
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
}
export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
fields.flatMap (field => {
const path = prefix ? `${prefix}.${field.key}` : field.key
return [path, ...countChildPaths (field.children ?? [], path)]
})
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
new Set (params.has ('children') ? params.get ('children')!.split (',') :
Object.entries (nested).flatMap (([key, fields]) =>
countChildPaths (fields, key)))
export const readCountSort = (value: string | null): CountSort[] =>
(value ?? '').split (',').flatMap (entry => {
const [key, direction] = entry.split (':')
return (key && (direction === 'asc' || direction === 'desc'))
? [{ key, direction }] : []
})
export const writeCountSort = (sort: CountSort[]): string =>
sort.map (item => `${item.key}:${item.direction}`).join (',')
export const changeCountSort = (
sort: CountSort[],
key: string,
additive: boolean,
): CountSort[] => {
const previous = sort.find (item => item.key === key)
if (!additive)
return [{ key, direction: previous?.direction === 'asc' ? 'desc' : 'asc' }]
if (previous == null)
return [...sort, { key, direction: 'asc' }]
return previous.direction === 'desc'
? sort.filter (item => item.key !== key)
: sort.map (item => item.key === key ? { key, direction: 'desc' } : item)
}
export const selectedCountFields = (
fields: CountField[],
params: URLSearchParams,
selectionKey: string,
parent = '',
): CountField[] => {
const selected = params.has (selectionKey)
? new Set (params.get (selectionKey)!.split (',')) : null
return orderedCountFields (fields, params, parent)
.filter (field => selected == null || selected.has (field.key))
}
export const normaliseCountsParams = (
resource: CountsResource,
source: URLSearchParams,
): URLSearchParams => {
const params = new URLSearchParams (source)
params.delete ('event_type')
const legacy = params.get ('mode')
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
{
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
if (legacy !== 'snapshot') params.set ('axis', resource === 'posts' ? 'tags' : 'month')
if (legacy === 'trend')
{
if (params.get ('before')) params.set ('at', params.get ('before')!)
params.delete ('from')
params.delete ('before')
}
}
if (!params.has ('mode')) params.set ('mode', 'summary')
const axes = (resource === 'posts' && params.get ('basis') !== 'history')
? ['tags', 'month', 'user'] : ['month', 'user']
if (!axes.includes (params.get ('axis') ?? '')) params.set ('axis', axes[0])
params.delete ('grain')
if (params.has ('columns'))
{
const selected = new Set (params.get ('columns')!.split (','))
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
.join (','))
}
return params
}
export const isCountsState = (params: URLSearchParams): boolean =>
params.get ('basis') !== 'history'
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params)
const hidden = isCountsState (params) ? ['latest'] : ['kind']
for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
'nested_json', 'order', 'basis', 'grain', ...hidden])
result.delete (key)
return result
}
export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): Promise<CountsResult> => apiGet (
isCountsState (params) ? '/counts/state' : `/counts/${resource}`, {
locale, params: { ...Object.fromEntries (countsRequestParams (params)),
...(isCountsState (params) ? { resource } : { }) } })
export const exportCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
format: 'json' | 'csv',
columns: string[],
headers: string[],
labels: Record<string, string>,
): Promise<Blob> => apiPost ('/counts/export', {
...Object.fromEntries (params), resource, format,
columns: columns.join (','), headers: JSON.stringify (headers),
labels: JSON.stringify (labels) }, { locale, responseType: 'blob' })
const stableValue = (value: unknown): unknown => {
if (Array.isArray (value)) return value.map (stableValue)
if (value != null && typeof value === 'object')
{
return Object.fromEntries (Object.entries (value)
.sort (([a], [b]) => a < b ? -1 : (a > b ? 1 : 0))
.map (([key, entry]) => [key, stableValue (entry)]))
}
return value
}
const compareValue = (first: unknown, second: unknown): number => {
if (Array.isArray (first) && Array.isArray (second) && first.length !== second.length)
return first.length - second.length
if (typeof first === 'number' && typeof second === 'number') return first - second
if (typeof first === 'boolean' && typeof second === 'boolean')
return Number (first) - Number (second)
const left = typeof first === 'object' ? JSON.stringify (stableValue (first)) : String (first)
const right = typeof second === 'object'
? JSON.stringify (stableValue (second)) : String (second)
return left < right ? -1 : (left > right ? 1 : 0)
}
export const sortedCountChildren = (
rows: CountRecord[],
path: string,
params: URLSearchParams,
valueFor: (row: CountRecord, key: string) => unknown = (row, key) => row[key],
): CountRecord[] => {
const sort = readCountSort (params.get ('child_sort'))
.filter (item => item.key.startsWith (`${path}/`))
return rows.map ((row, index) => ({ row, index })).sort ((first, second) => {
for (const item of sort)
{
const key = item.key.slice (path.length + 1)
const left = valueFor (first.row, key)
const right = valueFor (second.row, key)
if (left == null && right != null) return 1
if (left != null && right == null) return -1
if (left == null && right == null) continue
const compared = compareValue (left, right)
if (compared !== 0) return item.direction === 'asc' ? compared : -compared
}
return first.index - second.index
}).map (item => item.row)
}
export const countsCSV = (rows: string[][]): string =>
'\uFEFF' + rows.map (row => row.map (value => {
const safe = /^[=+@\-\t\r]/.test (value) ? `'${value}` : value
return `"${safe.replaceAll ('"', '""')}"`
}).join (',')).join ('\r\n')
+70
ファイルの表示
@@ -0,0 +1,70 @@
export type CountsClipboard = { text: string; html: string }
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
export const countsClipboard = (rows: string[][], headers = false): CountsClipboard => {
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
// Prefix formula-like text in both representations, including leading whitespace.
const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value)
? `'${value}` : value))
const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value)
? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n')
const html = '<html><body><table>' + safe.map ((row, index) => '<tr>' + row.map (value => {
const tag = headers && index === 0 ? 'th' : 'td'
// Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae.
const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value)
const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"'
return `<${tag} style="${escapeHTML (style)}">${escapeHTML (value)
.replace (/\r\n|\r|\n/g, '<br>')}</${tag}>`
}).join ('') + '</tr>').join ('') + '</table></body></html>'
return { text, html }
}
export const writeCountsClipboard = async (payload: CountsClipboard): Promise<void> => {
if (navigator.clipboard?.write != null && typeof ClipboardItem !== 'undefined')
{
try
{
await navigator.clipboard.write ([new ClipboardItem ({
'text/plain': new Blob ([payload.text], { type: 'text/plain' }),
'text/html': new Blob ([payload.html], { type: 'text/html' }) })])
return
}
catch
{
// Browsers without HTML clipboard support may still permit plain TSV.
}
}
if (navigator.clipboard?.writeText != null)
{
try
{
await navigator.clipboard.writeText (payload.text)
return
}
catch
{
// Try the synchronous copy event when clipboard permission is unavailable.
}
}
// The synchronous copy event fallback is also used on non-secure local origins.
const listener = (event: ClipboardEvent) => {
event.preventDefault ()
event.stopImmediatePropagation ()
event.clipboardData?.setData ('text/plain', payload.text)
event.clipboardData?.setData ('text/html', payload.html)
}
document.addEventListener ('copy', listener, true)
try
{
if (!document.execCommand ('copy')) throw new Error ('Clipboard unavailable')
}
finally
{
document.removeEventListener ('copy', listener, true)
}
}
+246
ファイルの表示
@@ -0,0 +1,246 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { translateUIMessage } from '@/lib/i18n'
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
import type { CountColumn, CountField, CountRecord,
CountsResource, CountsVersion } from '@/lib/counts'
import type { LocaleCode } from '@/lib/locales'
export const countsCellText = (
row: CountsVersion,
column: CountColumn,
locale: LocaleCode,
): string => {
switch (column)
{
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 'title': return row.title ?? ''
case 'url': return row.url ?? ''
case 'thumbnail':
case 'thumbnail_base': return row.thumbnailBase ?? ''
case 'name': return row.name ?? ''
case 'category':
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
case 'original_created_at':
return originalCreatedAtString (
row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale)
case 'original_created_from':
case 'original_created_before':
{
const value = column === 'original_created_from'
? row.originalCreatedFrom : row.originalCreatedBefore
return value == null ? '' : localisedDateString (value, locale)
}
case 'deprecated':
return row.deprecated == null ? '' : localisedDateString (row.deprecated, locale)
case 'user':
if (row.createdByUser == null)
return translateUIMessage ('history:bot', 'counts', locale).text
return row.createdByUser.name
? `${row.createdByUser.name} (#${row.createdByUser.id})`
: translateUIMessage ('history:anonymous', 'counts', locale, {
id: row.createdByUser.id }).text
case 'created_at': return localisedDateString (row.createdAt, locale)
default: return ''
}
}
export const countsCSVCell = (
row: CountsVersion,
column: CountColumn,
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): string => {
const field = fieldsForCounts (resource).find (field => field.key === column)!
if (field.children != null)
{
const rows = row[column as keyof CountsVersion] as CountRecord[] ?? []
return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
}
return countsCellText (row, column, locale)
}
const countsSectionRanges = (value: unknown, locale: LocaleCode): string[] | null => {
if (!Array.isArray (value)) return null
const time = (ms: number): string => {
const parts = msToTime (ms).split (':')
if (parts.length === 2) parts[0] = parts[0].padStart (2, '0')
return parts.join (':')
}
return value.map ((section: { beginMs: number; endMs: number | null }) =>
translateUIMessage ('sections.range', 'counts', locale, {
begin: time (section.beginMs),
end: section.endMs == null ? '' : time (section.endMs) }).text)
}
const displayNames = new Map<string, Intl.DisplayNames> ()
export const countsLogicalName = (
row: CountRecord,
field: string,
locale: LocaleCode,
): { text: string; language?: string } => {
const value = row[field]
if (value == null) return { text: '' }
if (field === 'sections')
return { text: (countsSectionRanges (value, locale) ?? []).join (
translateUIMessage ('sections.separator', 'counts', locale).text) }
const code = String (value)
if (field === 'languageCode' || field === 'scriptCode')
{
if (field === 'languageCode' && ['ja', 'ain'].includes (code))
return translateUIMessage (`history:languageNames.${code}`, 'counts', locale)
const master = row[field === 'languageCode' ? 'languageName' : 'scriptName']
if (typeof master === 'string' && master.trim () !== ''
&& master.toLowerCase () !== code.toLowerCase ())
return { text: master }
try
{
const type = field === 'languageCode' ? 'language' : 'script'
if (!displayNames.has (type))
displayNames.set (type, new Intl.DisplayNames (['ja'], { type, fallback: 'none' }))
const text = displayNames.get (type)!.of (code)
if (text != null && text !== code) return { text, language: 'ja' }
}
catch
{
// Invalid or unregistered codes have no logical label; never expose the code as a name.
}
return translateUIMessage ('notRegistered', 'counts', locale)
}
if (field === 'kind' || field === 'category')
return translateUIMessage (field === 'kind' ? `kinds.${code}`
: `common:categories.${code}`, 'counts', locale)
if (typeof value === 'boolean')
return translateUIMessage (`boolean.${value}`, 'counts', locale)
return { text: String (value) }
}
export const countsNestedOrder = (
rows: CountRecord[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountRecord[] => sortedCountChildren (rows, path, params, (row, key) =>
['languageCode', 'scriptCode'].includes (key)
? countsLogicalName (row, key, locale).text : row[key])
export const countsNestedExport = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): unknown => {
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0) return rows.map (() => ({ }))
return countsNestedOrder (rows, path, params, locale).map (row =>
Object.fromEntries (columns.map (field => [
translateUIMessage (field.label, 'counts', locale).text,
field.children
? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: (field.key === 'sections'
? countsSectionRanges (row[field.key], locale)
: countsLogicalName (row, field.key, locale).text)])))
}
export const countsNestedText = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): string => {
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0)
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
const value = field.children
? countsNestedText (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: countsLogicalName (row, field.key, locale).text
return `${translateUIMessage (field.label, 'counts', locale).text}: ${value}`
}).join (' | ')).join ('\n')
}
export const countsCellCopy = (
row: CountsVersion,
field: CountField,
params: URLSearchParams,
locale: LocaleCode,
): string => {
if (field.children == null) return countsCellText (row, field.key as CountColumn, locale)
const rows = row[field.key as keyof CountsVersion] as CountRecord[] ?? []
return params.get ('nested_json') === '1'
? JSON.stringify (
countsNestedExport (rows, field.children, field.key, params, locale), null, 2)
: countsNestedText (rows, field.children, field.key, params, locale)
}
// Historical URLs are displayed without fetching current attachments or metadata.
export const countsImageURL = (value: string | null | undefined): string | null => {
if (!value) return null
try
{
const url = new URL (value)
return (['https:', 'http:'].includes (url.protocol) && !url.username && !url.password)
? url.href : null
}
catch
{
return null
}
}
export const countsExportLabels = (
resource: CountsResource,
locale: LocaleCode,
): Record<string, string> => {
const aliases: Record<string, string> = { versionNo: 'version_no',
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
const labels: Record<string, string> = { }
const visit = (fields: CountField[]) => {
for (const field of fields)
{
labels[aliases[field.key] ?? field.key] = translateUIMessage (
field.label, 'counts', locale).text
if (field.children) visit (field.children)
}
}
visit (fieldsForCounts (resource))
for (const key of ['internal', 'external'])
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
labels[`category:${key}`] = translateUIMessage (
`common:categories.${key}`, 'counts', locale).text
for (const key of ['true', 'false'])
labels[`boolean:${key}`] = translateUIMessage (`boolean.${key}`, 'counts', locale).text
for (const key of ['ja', 'ain'])
labels[`language:${key}`] = translateUIMessage (
`history:languageNames.${key}`, 'counts', locale).text
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
return labels
}
+94
ファイルの表示
@@ -0,0 +1,94 @@
import { isCountsState, selectedCountFields } from '@/lib/counts'
import { translateUIMessage } from '@/lib/i18n'
import type { CountField, CountsResource, CountsResult } from '@/lib/counts'
import type { LocaleCode } from '@/lib/locales'
export type CountsSummaryRow = {
key: string | number | null
name: string | null
count: number | null
kind?: 'internal' | 'external'
tagId?: number
deprecated?: string | null
at?: string }
export const countsSummaryFields = (
resource: CountsResource,
params: URLSearchParams,
): CountField[] => (isCountsState (params) && params.get ('axis') === 'tags')
? [{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'tag_id', label: 'counts:columns.resourceId' },
{ key: 'name', label: 'history:tagName' },
{ key: 'deprecated', label: 'counts:columns.deprecated' },
{ key: 'count', label: 'counts:state.postCount' }]
: [{ key: 'group', label: `counts:axes.${params.get ('axis') ?? 'month'}` },
{ key: 'count', label: isCountsState (params)
? `counts:state.totals.${resource}` : 'counts:metric' }]
export const selectedCountsSummaryFields = (
resource: CountsResource,
params: URLSearchParams,
): CountField[] => selectedCountFields (
countsSummaryFields (resource, params), params, 'summary_columns', 'summary')
export const countsSummaryRows = (data: CountsResult): CountsSummaryRow[] =>
data.stateRows != null
? data.stateRows.map (row => ({ ...row, key: `${row.kind}:${row.tagId}` }))
: data.groups ?? []
export const countsSummaryCell = (
row: CountsSummaryRow,
key: string,
locale: LocaleCode,
): { text: string; language?: string } => {
switch (key)
{
case 'group': return row.key == null
? translateUIMessage ('history:bot', 'counts', locale)
: { text: row.name ?? String (row.key) }
case 'count': return { text: row.count == null ? '—' : String (row.count) }
case 'tag_id': return { text: String (row.tagId) }
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale)
case 'name': return row.name == null
? translateUIMessage ('notRegistered', 'counts', locale) : { text: row.name }
case 'deprecated': return row.deprecated
? translateUIMessage ('common:states.deprecated', 'counts', locale) : { text: '' }
default: return { text: '' }
}
}
export const countsSummaryDrill = (
resource: CountsResource,
params: URLSearchParams,
row: CountsSummaryRow,
): string => {
const next = new URLSearchParams (params)
for (const key of ['sort', 'page']) next.delete (key)
next.set ('mode', 'details')
if (row.at) next.set ('at', row.at)
if (row.tagId != null)
{
next.set ('kind', row.kind!)
next.set ('tag', String (row.tagId))
}
else if (params.get ('axis') === 'user') next.set ('user_id', String (row.key ?? 'none'))
else
{
const first = `${row.key}-01T00:00:00Z`
const date = new Date (first)
date.setUTCMonth (date.getUTCMonth () + 1)
const last = date.toISOString ()
const from = next.get ('from')
const before = next.get ('before')
next.set ('from', (from != null && Date.parse (from) > Date.parse (first)) ? from : first)
next.set ('before',
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
}
return `/counts/${resource}?${next}`
}
+28
ファイルの表示
@@ -0,0 +1,28 @@
import { createContext, useContext } from 'react'
export const MIN_COUNT_WIDTH = 64
export const MAX_COUNT_WIDTH = 2000
export const defaultCountWidths: Record<string, number> = {
thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
video_ms: 120, original_created_at: 260, original_created_from: 240,
original_created_before: 240, name: 240, names: 480, category: 180,
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
period: 200, tag_id: 88, id: 88, versionNo: 88, kind: 140, sections: 260,
languageCode: 180, scriptCode: 200, autoGenerated: 120 }
type Layout = {
width: (family: string, key: string) => number
resize: (family: string, key: string, width: number) => void }
export const CountsLayoutContext = createContext<Layout | null> (null)
export const CountsTableScope = createContext ('')
export const useCountsTableLayout = (): Layout => {
const context = useContext (CountsLayoutContext)
if (context == null) throw new Error ('Counts table layout provider is missing')
return context
}
+1 -1
ファイルの表示
@@ -41,7 +41,7 @@ type DialogueFormOptions = { title: ReactNode
description?: ReactNode
body: (controls: DialogueFormControls) => ReactNode
cancelText?: ReactNode
size?: 'default' | 'large' }
size?: 'default' | 'large' | 'viewport' }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
+4
ファイルの表示
@@ -161,3 +161,7 @@ export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) =>
['theatres', locale, id, 'programmes'] as const }
export const countsKeys = {
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
+10 -10
ファイルの表示
@@ -91,36 +91,36 @@ export const originalCreatedAtString = (
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
const space = locale === 'ja' ? ' ' : '\u3000'
if (from && before)
const fromParts = from == null ? null : new Date (from.getTime () + 9 * 3_600_000)
const beforeParts = before == null ? null : new Date (before.getTime () + 9 * 3_600_000)
if (from && before && fromParts && beforeParts)
{
const diff = before.getTime () - from.getTime ()
if (diff <= 60_000 /* 1 分 */)
return format (from, 'second')
if (from.getMinutes () === 0 && before.getMinutes () === 0)
if (fromParts.getUTCMinutes () === 0 && beforeParts.getUTCMinutes () === 0)
{
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
if (from.getHours () === 0 && before.getHours () === 0)
if (fromParts.getUTCHours () === 0 && beforeParts.getUTCHours () === 0)
{
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
if (from.getDate () === 1 && before.getDate () === 1)
if (fromParts.getUTCDate () === 1 && beforeParts.getUTCDate () === 1)
{
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
if (from.getMonth () === 0 && before.getMonth () === 0
if (fromParts.getUTCMonth () === 0 && beforeParts.getUTCMonth () === 0
&& (31_449_600_000 /* 364 日 */ <= diff
&& diff < 31_708_800_000 /* 367 日 */))
{
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
}
}
}
}
}
const rtn = ([from ? format (from, 'second') : '',
'〜',
+3
ファイルの表示
@@ -0,0 +1,3 @@
{
"_untranslated": ["*"]
}
+123
ファイルの表示
@@ -0,0 +1,123 @@
{
"titles": {
"posts": "投稿の集計・明細",
"tags": "タグの集計・明細"
},
"target": "集計対象",
"run": "集計実行",
"conditions": "集計条件",
"output": "出力項目",
"filters": "抽出条件",
"mode": "表示",
"summary": "集計",
"details": "明細",
"axis": "集計軸",
"axes": {
"month": "月別(UTC)",
"user": "$t(history:actor)",
"tags": "タグ別"
},
"metric": "履歴レコード件数",
"total": "履歴レコード件数:{{count}}",
"csv": "CSV",
"csvScope": "表示中のページ・選択列を CSV 出力",
"empty": "該当する履歴はありません。",
"columns": {
"changedAt": "操作日時",
"resourceId": "Id.",
"postTitle": "投稿タイトル",
"postURL": "投稿 URL",
"tags": "記載タグ",
"deprecated": "廃止状態",
"latest": "最新フラグ",
"thumbnailURL": "サムネール元 URL",
"originalFrom": "オリジナルの投稿日時(開始境界)",
"originalBefore": "オリジナルの投稿日時(終了境界・未満)",
"language": "言語",
"kind": "内部・外部",
"sections": "記載区間",
"names": "言語別名称",
"thumbnailDisplay": "サムネール表示"
},
"fields": {
"from": "開始(端末の時刻帯・以上)",
"before": "終了(端末の時刻帯・未満)",
"post": "投稿 ID",
"tag": "タグ名または ID",
"actor": "操作者 ID",
"limit": "表示件数"
},
"latestOnly": "最新のみ",
"boolean": {
"true": "はい",
"false": "いいえ"
},
"kinds": {
"internal": "内部タグ",
"external": "外部タグ"
},
"nested": {
"toggle": "表内表を展開・折り畳み",
"selection": "親を OFF にしても子項目の選択は保持します。",
"noColumns": "出力項目が選択されていません。",
"asJSON": "内表を JSON 表示"
},
"sort": {
"action": "クリックで単独ソート。Ctrl または長押しでキーを追加・方向変更・解除。NULL は末尾。",
"structured": "親表の複数値は要素数、保存スナップショットの文字列表現の順で比較します。Ctrl または長押しで複数キーソート。子表のソートは親表に影響しません。",
"label": "{{column}}:{{direction}}、優先順位 {{priority}}",
"none": "ソートなし",
"ascending": "昇順",
"descending": "降順",
"unsortedLabel": "{{column}}:ソートなし",
"singleLabel": "{{column}}:{{direction}}"
},
"notRegistered": "名称未登録",
"selection": {
"table": "集計・明細表",
"hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。",
"copy": "コピー",
"copyHeaders": "見出し付きコピー",
"row": "行を選択",
"column": "列を選択",
"all": "すべて選択"
},
"resize": {
"column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
},
"latestValue": "最新",
"sections": {
"range": "{{begin}}~{{end}}",
"separator": "、"
},
"reorder": "{{column}}:出力項目の並べ替え",
"state": {
"list": "タグ別投稿件数一覧",
"trend": "タグ別投稿件数推移",
"details": "時点明細",
"history": "履歴集計",
"historyDetails": "履歴明細",
"at": "集計時点",
"boundary": "集計時点(UTC)",
"period": "時点(UTC)",
"grain": "粒度(UTC)",
"grains": {
"day": "日別",
"month": "月別",
"year": "年別"
},
"postCount": "投稿件数",
"totals": {
"posts": "投稿総数",
"tags": "タグ総数(内部タグ)",
"assignments": "タグ付け総件数"
}
},
"measure": "指標",
"export": "エクスポート",
"exportFormat": "エクスポート形式",
"exportFormats": {
"json": "JSON",
"csv": "CSV"
}
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
import CountsResourcePage from '@/pages/CountsResourcePage'
const CountsPage = () => <CountsResourcePage resource="posts"/>
export default CountsPage
+157
ファイルの表示
@@ -0,0 +1,157 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CountsPage from '@/pages/CountsPage'
import CountsResourcePage from '@/pages/CountsResourcePage'
import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts'
const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchTagByName: vi.fn () }))
vi.mock ('@/lib/counts', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/tags', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/tags')> (), fetchTagByName }))
beforeEach (() => {
fetchCounts.mockReset ()
fetchTagByName.mockReset ()
fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 })
})
const conditions = () => screen.getByRole ('button', { name: '集計条件' })
const execute = () => fireEvent.submit (
screen.getByRole ('button', { name: '集計実行' }).closest ('form')!)
describe ('counts workspace', () => {
it ('opens the posts workspace at the root with a resource sidebar', async () => {
renderWithProviders (<CountsPage/>, { route: '/ja/counts' })
fireEvent.click (screen.getByRole ('button', { name: '集計対象' }))
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('href', '/ja/counts/posts')
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('aria-current', 'page')
expect (screen.getByRole ('link', { name: 'タグ' }))
.toHaveAttribute ('href', '/ja/counts/tags')
expect (conditions ()).toHaveAttribute ('aria-expanded', 'false')
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month'))
})
it ('preserves draft filters on collapse without querying until execution', async () => {
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?page=3&mode=details' })
await screen.findByText ('履歴レコード件数:0')
const calls = fetchCounts.mock.calls.length
fireEvent.click (conditions ())
const output = document.querySelector ('[data-counts-output]')!
const filters = document.querySelector ('[data-counts-filters]')!
expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy ()
fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), {
target: { value: '7' } })
fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), {
target: { value: '9' } })
fireEvent.click (conditions ())
expect (screen.getByRole ('table')).toBeInTheDocument ()
fireEvent.click (conditions ())
expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7')
expect (fetchCounts).toHaveBeenCalledTimes (calls)
execute ()
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('resource_id')).toBe ('7')
expect (params.get ('user_id')).toBe ('9')
expect (params.has ('page')).toBe (false)
})
const refreshed = fetchCounts.mock.calls.length
execute ()
await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed))
})
it ('applies selected columns only on execution and retains pagination state', async () => {
fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20,
versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' },
title: { current: 'Snapshot' } }] as CountsResult['versions'] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' })
expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument ()
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2')
fireEvent.click (conditions ())
expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked ()
fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' }))
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title')
execute ()
await waitFor (() => {
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url')
})
})
it ('removes legacy event fields and axes from public query state', async () => {
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=event_type&event_type=update&columns=event_type,name' })
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('axis')).toBe ('month')
expect (params.has ('event_type')).toBe (false)
expect (params.get ('columns')).toBe ('name')
})
fireEvent.click (conditions ())
expect (screen.queryByText ('操作種別')).not.toBeInTheDocument ()
expect (screen.getByRole ('combobox', { name: '集計軸' })).toHaveValue ('month')
})
it ('resolves a tag name with the existing lookup before running the query', async () => {
fetchTagByName.mockResolvedValue ({ id: 12 })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts' })
fireEvent.click (conditions ())
fireEvent.change (screen.getByRole ('textbox', { name: 'タグ名または ID' }), {
target: { value: '日本語タグ' } })
execute ()
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('tag')).toBe ('12'))
expect (fetchTagByName).toHaveBeenCalledWith ('日本語タグ', 'ja')
})
it ('uses Japanese fallback metadata in the Ainu workspace without exposing keys', async () => {
const { container } = renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ain-Kana/counts/tags' })
const title = screen.getByRole ('heading', { name: 'タグの集計・明細' })
expect (title.querySelector ('[lang="ja"]')).toHaveTextContent ('タグの集計・明細')
expect (conditions ().querySelector ('[lang="ja"]')).toBeInTheDocument ()
fireEvent.click (conditions ())
await screen.findByRole ('table')
expect (container.textContent).not.toMatch (/counts:|columns\.|fields\.|axes\./)
})
it ('drills through an actor group while preserving the existing filters', async () => {
fetchCounts.mockResolvedValue ({ count: 3, groupCount: 1, page: 1, limit: 20,
groups: [{ key: 9, name: 'Actor name', count: 3 }] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=user&resource_id=7' })
const link = await screen.findByRole ('link', { name: 'Actor name' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('resource_id')).toBe ('7')
expect (url.searchParams.get ('user_id')).toBe ('9')
expect (url.searchParams.get ('mode')).toBe ('details')
})
it ('keeps partial-month boundaries when drilling through to details', async () => {
fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20,
groups: [{ key: '2024-01', name: null, count: 4 }] })
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z' })
const link = await screen.findByRole ('link', { name: '2024-01' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z')
expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z')
expect (url.searchParams.get ('mode')).toBe ('details')
})
})
+186
ファイルの表示
@@ -0,0 +1,186 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import CountsControls from '@/components/counts/CountsControls'
import CountsSummaryResults from '@/components/counts/CountsSummaryResults'
import CountsResults from '@/components/counts/CountsResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import PageHead from '@/components/common/PageHead'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { countsRequestParams, exportCounts, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { selectedCountsSummaryFields } from '@/lib/countsState'
import { countsKeys } from '@/lib/queryKeys'
import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource } from '@/lib/counts'
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const [source, setParams] = useSearchParams ()
const params = normaliseCountsParams (resource, source)
const canonical = params.toString ()
const controlParams = new URLSearchParams (params)
controlParams.delete ('nested_json')
controlParams.delete ('order')
const request = countsRequestParams (params)
const [open, setOpen] = useState (false)
const [sidebarOpen, setSidebarOpen] = useState (false)
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const mode = params.get ('mode') ?? 'summary'
const state = isCountsState (params)
const cacheParams = new URLSearchParams (request)
cacheParams.set ('basis', state ? 'state' : 'history')
const [format, setFormat] = useState<'json' | 'csv'> ('json')
const [exporting, setExporting] = useState (false)
const [exportError, setExportError] = useState (false)
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
queryFn: () => fetchCounts (resource, params, locale), retry: false,
placeholderData: (previous, query) => {
const previousParams = new URLSearchParams (String (query?.queryKey[3] ?? ''))
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
&& previousParams.get ('mode') === mode
&& previousParams.get ('axis') === params.get ('axis')
&& previousParams.get ('basis') === cacheParams.get ('basis')) ? previous : undefined
} })
useEffect (() => {
if (source.toString () !== canonical)
setParams (canonical, { replace: true })
}, [canonical, source, setParams])
useEffect (() => {
if (data?.at == null || isPlaceholderData) return
const next = new URLSearchParams (canonical)
if (!next.get ('at')) next.set ('at', data.at)
if (next.toString () !== canonical) setParams (next, { replace: true })
}, [canonical, data, isPlaceholderData, setParams])
const update = (key: string, value: string) => {
const next = new URLSearchParams (params)
if (value || key === 'sort') next.set (key, value)
else next.delete (key)
if (key === 'sort') next.delete ('page')
setParams (next)
}
const download = async () => {
setExporting (true)
setExportError (false)
try
{
const fields = mode === 'summary' ? selectedCountsSummaryFields (resource, params)
: selectedCountColumns (resource, params).map (column =>
fieldsForCounts (resource).find (field => field.key === column)!)
const file = await exportCounts (resource, params, locale, format,
fields.map (field => field.key), fields.map (field => message (field.label).text),
countsExportLabels (resource, locale))
const url = URL.createObjectURL (file)
const link = document.createElement ('a')
link.href = url
link.download = `counts-${resource}.${format}`
link.click ()
setTimeout (() => URL.revokeObjectURL (url), 1000)
}
catch
{
setExportError (true)
}
finally
{
setExporting (false)
}
}
return (
<CountsTableLayout>
<div data-sidebar-container
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<PageHead title={message (`titles.${resource}`)}/>
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
aria-expanded={sidebarOpen} aria-controls="counts-targets"
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
<SidebarComponent sidebarKey="counts" maxWidth={224}
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
<nav id="counts-targets" aria-label={message ('target').text}
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => {
const next = new URLSearchParams (params)
for (const key of ['resource_id', 'tag', 'columns', 'summary_columns',
'sort', 'page', 'children', 'child_sort']) next.delete (key)
return <li key={target}><PrefetchLink
to={`/counts/${target}?${normaliseCountsParams (target, next)}`}
aria-current={target === resource ? 'page' : undefined}
className={cn ('block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>
})}
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="shrink-0">
<CountsControls key={`${resource}:${controlParams}`}
resource={resource} applied={params}
open={open} setOpen={setOpen}
reorder={value => update ('order', value)}
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}} actions={<>
<select value={format} aria-label={message ('exportFormat').text}
lang={message ('exportFormat').language}
className={inputClass (false, 'w-auto py-1')}
onChange={event => setFormat (event.target.value as 'json' | 'csv')}>
{(['json', 'csv'] as const).map (value => (
<option key={value} value={value}>
{message (`exportFormats.${value}`).text}</option>))}
</select>
<Button type="button" variant="outline" size="sm"
disabled={data == null || isError || isPlaceholderData
|| exporting || !params.get ('at')}
onClick={() => void download ()}>{label ('export')}</Button>
</>}/>
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
</div>
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
{data.at && <div className="my-1 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span>{label ('state.boundary')}:{data.at.replace ('T', ' ')}</span>
{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}
data={data} params={params}
pending={isPlaceholderData} update={update}/> : (
<CountsResults resource={resource} data={data} params={params}
pending={isPlaceholderData} update={update}/>)}
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>
</div>
</CountsTableLayout>)
}
export default CountsResourcePage