コミットを比較
7
コミット
main
...
feature/434
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
8642225b94 | ||
|
|
96a5bb8601 | ||
|
|
e71663bd22 | ||
|
|
52a8d62be5 | ||
|
|
3e05757820 | ||
|
|
d411b3b40f | ||
|
|
304090f717 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
class CountsPostsController < PostVersionsController
|
||||
include CountsListing
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
class CountsTagsController < TagVersionsController
|
||||
include CountsListing
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 } }]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
@import "@fontsource-variable/noto-sans-jp";
|
||||
@import './components/counts/countsTable.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
@@ -0,0 +1,70 @@
|
||||
export type CountsClipboard = { text: string; html: string }
|
||||
|
||||
|
||||
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') : '',
|
||||
'〜',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_untranslated": ["*"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import CountsResourcePage from '@/pages/CountsResourcePage'
|
||||
|
||||
|
||||
const CountsPage = () => <CountsResourcePage resource="posts"/>
|
||||
|
||||
export default CountsPage
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
新しいイシューから参照
ユーザーをブロックする